-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe: Querying subtrees with buildKey
The SQL-equivalent question this answers: hierarchical lookups against a tree of rows.
SELECT * FROM rentals WHERE state = 'TX'(every descendant of TX).SELECT * FROM rentals WHERE state = 'TX' AND facility = 'Dallas'(the Dallas row and its vehicles — self + children).SELECT * FROM rentals WHERE state = 'TX' AND facility LIKE 'Dal%'(facilities starting with "Dal"). In SQL you use materialized-path columns plusLIKE(or a recursive CTE); in DynamoDB it's the structural-key sort-attribute plusbegins_within theKeyConditionExpression.Pattern: declare composite
keyFields+ astructuralKey. Calladapter.buildKey(values, options?)to emit the rightKeyConditionExpressionfor the three natural subtree shapes — children-only (default), self + descendants ({self: true}), and narrow-prefix ({partial: 'Dal'}). Pipe the resulting params intogetListByParams/ cascade / any mass-op.
DynamoDB's KeyConditionExpression only accepts the partition-key equality plus one optional clause on the sort key — =, range comparisons (<, <=, >, >=), BETWEEN, or begins_with. That's the whole vocabulary. No JOIN, no CTE, no multi-clause filter in the KCE itself.
The workaround, pioneered by the single-table-design community, is to encode the hierarchy in the sort key itself: state|facility|vehicleVin. Once the hierarchy lives in the key string, the "descendants of X" query is a begins_with, a "narrow prefix" query is a longer begins_with, and DynamoDB plans the Query against the primary index directly (no FilterExpression, no post-fetch filtering).
Building the right prefix by hand is mechanical but error-prone:
- Zero-pad
{type: 'number'}components per theirwidthso lexicographic order matches numeric order. - Get the separator right, consistently (
|on writes,|on reads, same character). - Distinguish "children of X" (trailing separator in the prefix) from "self + children" (no trailing separator) correctly.
- Emit BOTH the partition-key equality AND the sort-key prefix clause in the same KCE — DynamoDB rejects either alone on a composite key.
adapter.buildKey(values, options?, params?) does all of that for you given the Adapter's declared keyFields + structuralKey. Three options cover the three shapes.
import {Adapter} from 'dynamodb-toolkit';
const rentals = new Adapter({
client: docClient,
table: 'rentals',
keyFields: ['state', 'facility', {name: 'vin', type: 'string'}],
structuralKey: '_sk', // joins with '|' — default separator
technicalPrefix: '_',
typeLabels: ['state', 'facility', 'vehicle'],
typeField: 'kind'
});Items look like:
// State-tier row: {state: 'TX', _sk: 'TX', kind: 'state', ...}
// Facility-tier row: {state: 'TX', _sk: 'TX|Dallas', kind: 'facility', facility: 'Dallas', ...}
// Vehicle-tier row: {state: 'TX', _sk: 'TX|Dallas|1HGC…', kind: 'vehicle', facility: 'Dallas', vin: '1HGC…', ...}The built-in prepare step composes _sk automatically — everything that follows reads from it.
"Vehicles under Dallas" — but not the Dallas facility row itself, and not anything else:
const params = rentals.buildKey({state: 'TX', facility: 'Dallas'});
params.TableName = rentals.table;
const result = await rentals.getListByParams(params, {limit: 20});
// result.data: [{vin, ...}, {vin, ...}, ...] — vehicles onlyEmitted on the wire:
KeyConditionExpression: #pk = :pk AND begins_with(#sk, :sk)
:pk = 'TX'
:sk = 'TX|Dallas|' ← trailing separator excludes the facility row itself
Use this when you have a clean "list the children, not the parent" access pattern — most tier-by-tier drill-down UI fits here.
Sugar: getListUnder(partialKey, options). Equivalent to getListByParams(buildKey(partialKey), options). Saves one line for the common case:
const vehicles = await rentals.getListUnder(
{state: 'TX', facility: 'Dallas'},
{limit: 20}
);For the {self} / {partial} shapes below, compose buildKey + getListByParams directly — getListUnder is only the default.
"The Dallas facility and everything under it":
const params = rentals.buildKey(
{state: 'TX', facility: 'Dallas'},
{self: true}
);
params.TableName = rentals.table;
const result = await rentals.getListByParams(params);
// result.data: [{kind: 'facility', ...}, {kind: 'vehicle', ...}, {kind: 'vehicle', ...}, ...]Emitted on the wire:
KeyConditionExpression: #pk = :pk AND begins_with(#sk, :sk)
:pk = 'TX'
:sk = 'TX|Dallas' ← NO trailing separator — matches 'TX|Dallas' AND 'TX|Dallas|…'
The implicit invariant. {self: true} works because no sibling at the parent's tier has a name that starts with 'Dallas' except 'Dallas' itself. If 'Dallas' and 'Dallasville' coexisted as facilities, begins_with(_sk, 'TX|Dallas') would return both plus their vehicles — surprising.
How to make the invariant hold:
- Use immutable surrogate IDs at each tier (UUIDs, numeric IDs with zero padding) — distinct values can't be prefixes of each other if they're the same length or use content-free encoding.
- If you must use human-readable names, enforce a name-uniqueness-plus-reservation check at insert time: no new facility name may be a prefix of an existing one, and vice versa. This is application-layer discipline; the toolkit can't enforce it at the DDB level.
-
typeLabelsprefix-collision is checked at adapter construction — the toolkit rejects e.g.typeLabels: ['state', 'facility', 'facil']because'facil'is a prefix of'facility'. The value-level invariant is harder: the toolkit can't enforce that no two facility names prefix-collide without reading every row.
When the invariant can't be guaranteed: use two Queries (kind = facility AND state = TX AND facility = Dallas for the row + buildKey({state, facility}) for children) and concatenate client-side. Clean, 2× the RCU.
Use cases for {self}.
- UI "drill-in" views where the parent header + its children list in the same page.
- Cascade operations that need to include the root (the toolkit's
deleteAllUnder/moveAllUnderdo this internally on the self-phase — without{self: true}in their pagination shape). - Data exports where every row under a scope counts, including the scope row itself.
"Facilities under TX whose name starts with 'Dal'" — Dallas, Dalton, Dalhart, …:
const params = rentals.buildKey(
{state: 'TX'},
{partial: 'Dal'}
);
params.TableName = rentals.table;
const result = await rentals.getListByParams(params);
// result.data: the facility rows (and anything under them) whose name starts with 'Dal'Emitted on the wire:
KeyConditionExpression: #pk = :pk AND begins_with(#sk, :sk)
:pk = 'TX'
:sk = 'TX|Dal' ← narrows the next tier by the partial prefix
Use cases:
- Autocomplete / typeahead against the next tier's name.
- Browsing "everything with a name starting with X" — ZIP codes, SKU prefixes, product codes.
- Quick filters where the tier's values have a natural prefix hierarchy.
Precedence when combined with {self}: partial wins — they target different key ranges and can't be merged into a single begins_with. If you need both ("self + children, narrowed"), do it in two Queries.
Partial is a string, not a value. The partial string replaces the next tier's supplied value — you don't supply the next-tier field in values when using {partial}. The toolkit throws if you try: buildKey({state: 'TX', facility: 'Dallas'}, {partial: 'Ma'}) is ambiguous and rejected.
The toolkit's buildKey targets the base table only. Calling buildKey({...}, {indexName: 'by-kind'}) throws today — declarative GSI-key composition is future work.
For GSI-targeted KeyConditionExpression, drop to the lower-level buildKeyCondition(input, params?) primitive:
import {buildKeyCondition} from 'dynamodb-toolkit/expressions';
const params = buildKeyCondition({
name: 'createdAt', // GSI sort-key attribute
value: '2026-04-01',
kind: 'prefix', // begins_with
pkName: 'status', // GSI partition-key attribute
pkValue: 'active' // GSI partition-key value
});
params.TableName = rentals.table;
params.IndexName = 'by-status-createdAt';
const result = await rentals.getListByParams(params);The primitive takes a fully-prepared value string — you're responsible for composing the sort-key shape (no keyFields awareness, no zero-padding). Fine when the GSI schema is simple; inconvenient when the GSI also has a composite sort key.
When this becomes painful enough to matter, file a request — declarative GSI buildKey is on the roadmap.
buildKey only emits = or begins_with. For <, <=, >, >=, BETWEEN on the sort key (e.g. "events between timestamps"), drop to buildKeyCondition with kind: 'exact' for the partition key alone, then add the range condition by hand or via adapter.applyFilter (which promotes valid range clauses to the KCE automatically).
buildKey returns a params object with the KeyConditionExpression + attribute maps populated. It pairs naturally with any mass-op that takes Query/Scan params:
const params = rentals.buildKey({state: 'TX', facility: 'Dallas'});
params.TableName = rentals.table;
// Read a page:
const page = await rentals.getListByParams(params, {limit: 20});
// Delete everything under Dallas (non-cascade — just the children):
const deleted = await rentals.deleteListByParams(params, {maxItems: 5000});
// Clone everything under Dallas to Austin:
const cloned = await rentals.cloneListByParams(
params,
rentals.swapPrefix({facility: 'Dallas'}, {facility: 'Austin'}),
{maxItems: 5000}
);
// Patch every item under Dallas to mark inactive:
const edited = await rentals.editListByParams(
params,
vehicle => ({...vehicle, active: false}),
{maxItems: 5000}
);The cascade primitives (deleteAllUnder etc.) compose buildKey(srcKey) internally. The cascade recipe documents that flow; this recipe's use case is the read / hand-rolled-mass-op path where you want subtree queries without the cascade's self-phase semantics.
values must be contiguous-from-start: you can omit trailing keyFields, but you can't skip a middle one. buildKey({state: 'TX', vin: '1HGC…'}) throws — facility is missing, vin can't be reached. The error names which field is the gap.
This matches DynamoDB's sort-key semantics: the structural-key prefix is composed from the leftmost values you supply, in keyFields order.
Every buildKey-composed Query hits the base-table primary index (or — once declarative GSI lands — a GSI). No FilterExpression, no post-fetch filtering — DynamoDB plans against the sorted sort-key space directly, reads exactly the key range you asked for.
| Shape | Partition key | Sort-key condition | Typical RCU |
|---|---|---|---|
buildKey({pk}) (default, single-field) |
= :pk |
— (none) | 1 Query; 1 RCU per 4 KB scanned |
buildKey({pk, ...}) (children) |
= :pk |
begins_with('X|Y|') |
1 Query; scanned bytes = only matching children |
buildKey({pk, ...}, {self: true}) |
= :pk |
begins_with('X|Y') |
same — includes the self row |
buildKey({pk}, {partial}) |
= :pk |
begins_with('X|Prefix') |
same — bounded by the prefix |
DynamoDB bills per scanned byte, not per matched byte — so begins_with on a sort key is cheaper than Scanning the whole partition or using a base-table FilterExpression. That's the whole point of encoding hierarchy in the key.
- Your data has a natural 2-5-tier hierarchy (state > facility > vehicle, tenant > project > issue, org > department > user).
- Reads against subtrees are a primary access pattern (UI drill-down, exports, cascade ops).
- Values at each tier don't prefix-collide with their siblings (or you control insertion to enforce it).
- You use composite
keyFields+structuralKeyon the base-table primary index.
-
Flat namespaces — no hierarchy, single-field
keyFields.buildKeythrows on{self}/{partial}options in this case; the sugar evaporates. -
Sibling values prefix-collide at the tier you're querying.
{self: true}would over-match. Use two Queries and concatenate, or use surrogate IDs. -
Range queries on the sort key — use
buildKeyConditiondirectly or add conditions viaapplyFilter. -
GSI-targeted queries — drop to
buildKeyConditionor hand-build the KCE until the toolkit's declarative GSI-key surface lands. -
Very shallow hierarchies (pk alone, no sk). You don't need any of this;
getByKeyis enough.
-
Adapter: Constructor options — Structural key — the declaration
buildKeyconsumes. -
Concepts → Structural key — how composite
keyFieldscompose the sort-key attribute. -
Concepts →
KeyConditionExpression— the three ways to build a KCE (primitive / ergonomic / hand-written). - Key and field design → Sort-key selection — when hierarchical sort keys are the right shape.
-
Recipe: Cascade subtree operations — the cascade primitives compose
buildKey(srcKey)internally for subtree delete / clone / move. - Recipe: List records of a tier within a partition — the LSI alternative when the subtree shape doesn't fit.
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History