# accesspatterns.dev - full course text > Learn DynamoDB access patterns by running real queries in your browser and seeing what each one costs. A real DynamoDB-compatible engine, backed by SQLite, runs in the tab. The in-browser engine is a preview build. It has not been run against the conformance suite that backs dynoxide's native build, so treat its behaviour as illustrative rather than authoritative. # Lessons ## Foundations What an item is, and how the key addresses it. ### Items, not rows Two items in one table can have completely different attributes. A DynamoDB item is a bag of typed attributes, not a row in a fixed set of columns. The only attribute every item must have is the primary key. Read the book, then the lamp. They live in the same table and share nothing but their id. Each attribute carries its type with it: S for a string, N for a number, BOOL for a boolean, SS for a string set. Takeaway: Only the key is fixed. Every other attribute is decided item by item. ### The key is the address GetItem needs the whole primary key: partition and sort. This table has a composite key: a partition key (pk, the room) and a sort key (sk, the message). The partition key decides which partition an item lives on; the sort key orders items within that partition. GetItem fetches exactly one item, and it needs the whole key. Knowing only the room is not enough to GetItem a message. That is a job for Query, next. Takeaway: Know the full key, pay for one item. GetItem is the cheapest read DynamoDB offers. ## Reads and cost The three ways to read, and what each one charges you. ### One query, a whole collection Items that share a partition key are read together in one Query. All the items sharing a partition key form an item collection, stored together and sorted by sort key. Query reads a slice of one collection. Here one Query returns the room and every message in it, in order, in a single round trip. The messages were written next to the room on purpose, so reading them back is one cheap operation rather than a join. Takeaway: Co-locate what you read together. Then a Query reads it all at once, cheaply. ### The sort key is a query language Conditions on the sort key narrow a Query before it reads, for free. On the sort key a Query can do more than equals: begins_with, a range with BETWEEN, or simple comparisons like greater-than. Because the partition is already sorted, these narrow the read before it happens. Run all three and watch the cost. Scanned never climbs above Returned: the key condition decided what to read, so you never pay for items you did not want. Takeaway: Sort-key conditions narrow before the read. Sorting your data is designing your queries. ### A filter is not an index A FilterExpression runs after the read: you pay for everything scanned. It is tempting to read this Scan as 'find the rock songs'. It is not. A Scan reads every item in the table, and the FilterExpression only drops some of them on the way out. The 1MB read limit and the bill are both applied before the filter. Run the Scan, then the index Query for the identical result. The Scan's Scanned is the whole table; the index Query's Scanned equals what it returned. Same answer, very different cost. Takeaway: You pay for Scanned, not Returned. A filter trims the result, never the bill. When you reach for a filter, reach for a key or an index instead. ### Read the latest first A sorted timestamp makes 'the most recent' a direction, not a sort in your code. Every reading from a fridge shares that fridge's partition key, and the sort key is an ISO-8601 timestamp. A fixed-width ISO timestamp sorts lexically in time order, so the newest reading is simply the last one in the partition. That makes 'the latest' a direction. ScanIndexForward false walks the partition newest-first, and a Limit stops it early. Run all three: direction and limit are request fields, not a second query and a sort back in your own code. Takeaway: Sort by a fixed-width timestamp and the latest N is a descending Query with a Limit, never a scan-then-sort. ### Read a page, keep the bookmark Limit caps a page; LastEvaluatedKey is the bookmark you feed back to resume. This feed has nine posts and we want them four at a time. Limit caps a page at four, then the response carries a LastEvaluatedKey, the key of the last row it read. That is a resumable bookmark, not an offset. Run the pages in order. Page two passes the bookmark back as ExclusiveStartKey and carries on. The last page returns one post and no LastEvaluatedKey at all: the missing bookmark is the end-of-results signal. There is no OFFSET in DynamoDB, and it never counts past where it stopped. Takeaway: LastEvaluatedKey is a resumable bookmark, and its absence means the last page. Loop until the bookmark is gone, never on a count. ## Writing data Putting, changing, and guarding items. ### PutItem replaces PutItem writes a whole item and overwrites any item with the same key. PutItem writes an entire item. If the key is new, you get a new item. If an item with that key already exists, PutItem replaces it wholesale: it does not merge. Add a track, then overwrite track 001 with a version that drops the seconds attribute. Look at the row: the old attribute did not survive. That is the footgun and the feature. Takeaway: PutItem is write-or-replace by key. To change part of an item without losing the rest, use UpdateItem. ### UpdateItem edits in place UpdateItem changes named attributes and can increment a counter atomically. UpdateItem changes the attributes you name and leaves everything else alone. SET assigns a value; ADD increments a number atomically on the server, so two concurrent increments can never lose each other. Rename the playlist, then count a play twice and watch plays climb. Note name uses an ExpressionAttributeNames placeholder because some attribute names are reserved words. Takeaway: UpdateItem edits in place. ADD gives you atomic counters with no read-modify-write race. ### Claim it, or fail A ConditionExpression lets a write happen only if the data still allows it. A PutItem normally overwrites whatever shares its key. A ConditionExpression guards it: the write happens only if the condition holds at the instant it runs, atomically, with no read-first race for two callers to lose. Claim a free callsign and attribute_not_exists(pk) passes. Claim one alice already holds and the engine rejects the write with a ConditionalCheckFailedException, leaving her record untouched. Read it back to confirm. This is how you register something exactly once. Takeaway: A condition turns a blind write into a guarded one. attribute_not_exists is a first-writer-wins claim, enforced at write time. ### No silent clobbers A version check lets an update land only if nobody changed the item since you read it. Two people open the same itinerary. Without a guard, the second to save silently overwrites the first. A version attribute fixes that: every write carries the version it expects and bumps it atomically with ADD. Update with the version you read and it lands, taking version from 3 to 4. Run the stale update and the condition version = 3 no longer holds, so the engine rejects it with a ConditionalCheckFailedException. The stale writer must re-read and retry. No update is ever lost. Takeaway: A version attribute plus a condition gives optimistic concurrency: a stale write bounces instead of silently overwriting a newer one. ## Modelling relationships One-to-many, secondary indexes, and sparse indexes. ### Model a one-to-many with one partition A parent and its children share a partition key, so one Query fetches them together. A playlist has many tracks. Instead of a join, both live in one item collection: the playlist's META record and its TRACK items all share the partition key PLAYLIST#chill. So 'the playlist and everything in it' is a single Query. The relationship was resolved at write time, by where you put the items, not at read time by joining them. Takeaway: Co-locate a parent and its children under one partition key, and the join disappears into a single Query. ### One key, every level A path in the sort key lets one begins_with read any level of a hierarchy. These weather stations live in one partition, with a sort key that encodes a path: country, then state, then city, joined by a delimiter. Because the partition is stored sorted, every station in a country sits together, and within it every station in a state sits together. So one begins_with reads any level of the tree. A US# prefix gives a country, US#CA# narrows to a state, and the full path is a single city. No FilterExpression appears anywhere: the hierarchy is in the key, and the key narrows the read before it happens. Takeaway: Encode a path into the sort key and one begins_with reads any level of the tree, in order, for free. ### A different key for a different question A secondary index re-keys your items so you can Query them on a new axis. The base table groups tickets by project. But 'every ticket assigned to Ada, across all projects' needs a different partition key, and you cannot change the table's keys. A global secondary index is the answer: it is an automatically-maintained, re-keyed copy of your items. Query the base table for a project; Query the index for an assignee. Same items, two axes. Takeaway: An index is a re-keyed copy of your data. Add one when an access pattern needs a key the table does not have. ### Traverse a relationship both ways An index that swaps the partition and sort keys reads the same edges from the other side. A maintainer belongs to many projects, and a project has many maintainers. Each membership is stored once, as an edge item keyed pk = PERSON, sk = PROJECT, carrying a second pair of keys that swap the two: gsi1pk = PROJECT, gsi1sk = PERSON. So the base table answers 'Ada's projects' and the inverted index answers 'httpkit's maintainers', from the very same edges. One relationship, two directions, nothing duplicated. The profile items carry no index keys, so only the edges appear in the index. Takeaway: Store a many-to-many as edges, then an inverted index, partition and sort swapped, reads the relationship from either side. ### Absence as a filter An item appears in an index only if it has the index's key, so leaving the key off filters it out. An item is copied into a secondary index only if it has every attribute in that index's key. In this table only open tickets are given a gsi1pk, so only open tickets exist in the by-assignee index. Scan the index, then the table. The index is a ready-made list of open work, with no filter expression and nothing wasted. Close a ticket by removing its gsi1pk and it drops out of the index on its own. Takeaway: A sparse index uses absence as a global filter: only the items you care about ever appear in it. ### Keep the total where you read it A denormalised count on the parent turns an aggregate into a single GetItem. How many replies does a thread have? You could read them all and count, but that read grows without bound as the thread fills up. Instead the count lives on the thread's META item as a denormalised attribute. So the total is a single GetItem, however long the thread runs. When a reply is posted, ADD bumps replyCount atomically on the server, so two replies at once can never lose each other. The trade is honest: the counter and the reply are two writes, so keeping them exactly in step needs a transaction. Takeaway: Store the aggregate on the parent and bump it with atomic ADD; reading the total is one GetItem, not a query-and-sum that grows. ## Single-table design One table, one index, many access patterns. ### One index, many questions Different entity types share generic index keys, so one index serves several access patterns. Here one table holds projects and tasks, and one index, GSI1, serves both. The trick is generic key names: projects write OWNER#ada into gsi1pk, tasks write STATUS#doing into the same attribute. So one index answers 'Ada's projects' and 'tasks in progress': two access patterns, two entity types, one set of index keys. This overloading is the heart of single-table design. Takeaway: Generic, overloaded keys let one index do the work of many. That is how a single table serves a whole application. ### An index holds only what you project Projection decides which attributes an index carries, trading storage against an extra read. A secondary index is a copy of your items, and you choose how much of each item it copies. The lean index here projects only the order total; the full index projects everything. Both are keyed on order status. Query the lean index and each row carries only its keys and total. Query the full index and every attribute is there. The lean index is cheaper to store and cheaper to write, but a read that needs the customer or address has to fetch it from the table, one GetItem per row. Project for the read you actually serve. Takeaway: KEYS_ONLY and INCLUDE indexes are cheap but make reads fetch the rest; ALL avoids the fetch but roughly doubles the index cost. ### One table, an application's worth of questions Generic keys and one overloaded index answer five access patterns with no join and no scan. This is the whole course in one table. A conference platform has conferences, sessions, speakers and registrations, and five questions to answer. Every item lives in one table, keyed generically, with one overloaded index, GSI1, shared across two entity types. Run the five examples. A conference and its agenda are one Query; a session and its attendees are another. GSI1 re-keys sessions by speaker and registrations by attendee, so the same index answers both 'every talk this speaker gives' and 'every session this attendee booked'. The fifth is a plain GetItem. Five access patterns, every one a Query or a GetItem, never a join and never a scan. Takeaway: Model backwards from your access patterns: generic keys and one overloaded index let a single table serve an entire application. # Models ## URL Shortener A hash-only table keyed by short code, the classic key-value lookup. The simplest single-table design: a partition key and no sort key. Each item maps a short code to a long URL plus a click counter. There is no Query here, only GetItem by code. ## SaaS Multi-Tenant Organisations and their members in one table, isolated by partition. A single-table design where every item for an organisation shares the partition key ORG#. Members live beside the org record, so the whole tenant reads in one Query. ## Game Leaderboard Per-game high scores, ranked with a secondary index on score. Scores live under the game partition, and a GSI re-keys them by score so the top of the table is a single descending Query on the index. ## Maintainers & Projects A many-to-many between people and projects, read from either side. Memberships are stored once, as edge items keyed by person then project. An inverted index swaps the keys, so 'a person's projects' reads the base table and 'a project's maintainers' reads the index, from the very same edges, with nothing duplicated.