-
Notifications
You must be signed in to change notification settings - Fork 0
Hierarchical data walkthrough
A start-to-finish walkthrough for SQL developers: take a three-table relational schema, pack it into one DynamoDB table with structured keys, and keep every access pattern indexed — no Scan, no FilterExpression where a key condition can do the work. The running example is the car-rental hierarchy shipped in examples/car-rental.
Read Concepts first for vocabulary (pk, sk, GSI, structural key, technicalPrefix); read Key and field design for the general key-selection rules this walkthrough applies.
A national car-rental company: states contain facilities, facilities contain vehicles.
CREATE TABLE states (state CHAR(2) PRIMARY KEY, ...);
CREATE TABLE facilities (state CHAR(2) REFERENCES states, facility TEXT, PRIMARY KEY (state, facility), ...);
CREATE TABLE vehicles (state CHAR(2), facility TEXT, vehicle TEXT,
PRIMARY KEY (state, facility, vehicle),
FOREIGN KEY (state, facility) REFERENCES facilities, ...);Typical queries: one record by full key; all facilities in a state; all vehicles at a facility; all vehicles in a state; cascade a delete from a state down. In SQL those are joins and ON DELETE CASCADE. In DynamoDB, all of them become range queries on one sorted index — if the keys are designed for it.
DynamoDB gives you exactly one cheap read primitive: a Query with a KeyConditionExpression (KCE) — an index seek that reads only matching items. A FilterExpression runs after items are read from disk: it shrinks the response, never the cost. So the design goal is: express every hot access pattern as a key condition.
The trick is a structural key — one technical attribute holding the joined logical keys:
| Record | Logical key | Structural key (_sk) |
|---|---|---|
| state | {state: 'TX'} |
TX |
| facility | {state: 'TX', facility: 'Dallas Rental'} |
TX|Dallas Rental |
| vehicle | {state: 'TX', facility: 'Dallas Rental', vehicle: '1FTEW…'} |
TX|Dallas Rental|1FTEW… |
Because the sort key is sorted, begins_with covers the hierarchy:
-
_sk = "TX"— the Texas state record. -
begins_with(_sk, "TX|Dallas Rental|")— every vehicle at Dallas Rental. -
begins_with(_sk, "TX|")— every facility and vehicle in Texas (excludes the state record itself — the trailing separator forces child-level matches).
Descending order is free (ScanIndexForward: false). The constraint you accept: sorting is by the composite, in declaration order — you can't sort a subtree by an arbitrary field without a secondary index (see Key and field design).
The toolkit turns the pattern into a declaration. The shipped example, trimmed to the structural essentials:
import {Adapter} from 'dynamodb-toolkit';
const adapter = new Adapter({
client,
table: 'car-rental-example',
technicalPrefix: '_',
// Three tiers: state → facility → vehicle.
keyFields: ['state', 'facility', 'vehicle'],
structuralKey: '_sk',
// Depth-based type labels, paired 1:1 with keyFields;
// `kind` is auto-stamped on write and read back by typeOf.
typeLabels: ['state', 'facility', 'vehicle'],
typeField: 'kind',
typeDiscriminator: 'kind',
// Cascade gate for deleteAllUnder / cloneAllUnder / moveAllUnder.
relationships: {structural: true}
});The built-in prepare step composes _sk on every write by walking keyFields in order and joining the contiguous-from-start defined fields:
-
{state: 'TX'}→_sk = "TX"— a state record. -
{state: 'TX', facility: 'Dallas Rental'}→"TX|Dallas Rental"— a facility. -
{state: 'TX', vehicle: '1FTEW…'}→ throws — non-contiguous (facilitymissing in the middle).
The depth of the key is the record's type — no discriminator column required, though one is supported (see Multi-type tables). Number components in a composite require width (zero-padding keeps lexicographic order honest: "9" > "10" without it).
Field values must not contain the separator (default '|', any string allowed) — sanitize on the way in, or pick a separator outside your value alphabet.
Nothing tier-specific — the key shape decides the tier:
await adapter.post({state: 'TX', name: 'Texas Operations'});
await adapter.post({state: 'TX', facility: 'Dallas Rental', phone: '…'});
await adapter.post({state: 'TX', facility: 'Dallas Rental', vehicle: '1FTEW1E53PKE00001', make: 'Ford', status: 'available'});Single records use the full logical key:
const car = await adapter.getByKey({state: 'TX', facility: 'Dallas Rental', vehicle: '1FTEW1E53PKE00001'});Subtrees use getListUnder (children of a partial key) or buildKey for the other shapes:
// All facilities + vehicles in Texas (children of the state):
const inTexas = await adapter.getListUnder({state: 'TX'}, {limit: 50});
// All vehicles at one facility:
const fleet = await adapter.getListUnder({state: 'TX', facility: 'Dallas Rental'});
// Self + descendants, or prefix-narrowing, compose buildKey + getListByParams:
const params = adapter.buildKey({state: 'TX'}, {self: true});
params.TableName = adapter.table;
const withStateRecord = await adapter.getListByParams(params, {limit: 50});The three buildKey shapes (children default, {self: true}, {partial: 'Dal'}) are covered in depth in Querying subtrees with buildKey. Both offset and cursor pagination work on every list read — see Pagination.
"All facilities in every state" can't ride the base table (each state is its own partition). That's the sparse-GSI-on-typeField pattern — one GSI keyed on the auto-stamped kind:
indices: {'by-kind': {type: 'gsi', pk: 'kind', sk: {name: '_sk', type: 'string'}, projection: 'all'}}const facilities = await adapter.getListByParams(
{
TableName: adapter.table,
IndexName: 'by-kind',
KeyConditionExpression: '#k = :k',
ExpressionAttributeNames: {'#k': 'kind'},
ExpressionAttributeValues: {':k': 'facility'}
},
{limit: 100}
);Three variants of this pattern (single GSI on typeField, per-tier sparse markers, tier-within-a-partition LSI) each have a recipe with full cost tables: start at List records of a tier.
relationships: {structural: true} unlocks the subtree macros — the DynamoDB equivalent of ON DELETE CASCADE, built from resumable mass operations:
await adapter.deleteAllUnder({state: 'TX'}); // leaf-first: vehicles, facilities, then the state record
await adapter.cloneAllUnder({state: 'TX'}, {state: 'FL'}); // root-first copy
await adapter.moveAllUnder({state: 'TX'}, {state: 'FL'}); // two-phase idempotent renameOrdering, crash-resumability, and the put-collision semantics are documented in Cascade subtree operations; the failure buckets are in Mass operation semantics.
The hierarchy maps onto URLs through keyFromPath — the handler's hook that turns the /:key segment into a logical key. For composite keys, pick a URL-safe delimiter that can't appear in values:
import {createHandler} from 'dynamodb-toolkit/handler';
const handler = createHandler(adapter, {
keyFromPath: (raw, adp) => {
const parts = raw.split(':'); // '/TX:Dallas Rental:1FTEW…'
const key = {};
adp.keyFields.forEach((f, i) => {
if (parts[i] !== undefined) key[f.name] = parts[i];
});
return key;
}
});GET /TX:Dallas%20Rental fetches the facility; the collection routes (GET /, DELETE /, /-clone, /-move) operate on filtered sets. URL-design trade-offs — including hierarchical path schemes like /TX/Dallas%20Rental/… built on plain framework routes — are the subject of URL schema design.
-
The hierarchy isn't stable. Structural keys freeze the field order. If "vehicles move between facilities" is a hot operation, every move rewrites the key (
moveByKeyshandles it, but it's a delete + put, not an in-place update). Frequent re-parenting wants an adjacency-list design instead — see Key expression patterns. - Sibling sort by arbitrary fields. The composite sorts by declaration order only. Each extra sort dimension is an LSI/GSI (declared, budgeted, provisioned).
- A tier is genuinely huge. One state's subtree shares one partition budget (~10 GB, 3000 RCU / 1000 WCU). Shard the partition key (date buckets, hash suffixes) before the ceiling is real — see Key and field design.
- Cross-hierarchy queries dominate. If most reads slice across the hierarchy (by status, by date) rather than down it, the structural key is just one more GSI away from being overhead — design the GSIs first and ask whether the base-table hierarchy still earns its place.
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