DynamoDB basics for DynoStudio
New to DynamoDB? Start here before the PartiQL dialect pages. This is the short mental model DynoStudio expects: items live in tables, keys decide what can be read cheaply, indexes add alternate read paths, a Scan is a warning sign until you know you want one — and whether you use one table or many is a design choice, not a given.
- Build the beginner mental model — tables, items, partition keys, and sort keys
- Tell a Query (targeted, cheap) apart from a Scan (reads — and bills for — everything)
- See how a GSI adds an alternate read path to the same table
- Understand why DynamoDB modeling starts from access patterns, not entities
- Know when to keep entities in separate tables, and when to reach for single-table design
The library picture
Imagine a huge library. The table is the library. An item is one book. The partition key tells you which shelf to visit. The sort key tells you where on that shelf to look.
A Query goes straight to the right shelf. A Scan walks every shelf and checks every book. A GSI is another set of shelf labels, so you can find the same books by email, status, region, or whatever your app needs.
That is the whole beginner picture. The rest of DynamoDB is mostly learning how to design good shelf labels before your app has millions of books.
Here is that library. Click the ways of reading it and watch which shelves get walked — and what a GSI really is (the same books, new shelf labels):
The shape of DynamoDB data
A DynamoDB table stores items. An item is a bag of attributes: strings, numbers, booleans, lists, maps, sets, and nulls. The table only requires the key attributes; everything else can vary from item to item. That is why DynamoDB feels flexible, but also why your access patterns matter early.
Here is one item from Bean & Bark, the coffee roaster you'll meet in the tutorial. The simplest way to lay this out is the way you would in SQL: a table per thing. Below is one row of a Customers table, keyed by CustomerId — shown as the table you already think in, or, one click away, as the JSON item DynamoDB actually stores:
| CustomerId PK partition key — one customer | Name | Region | |
|---|---|---|---|
| c-014 | Dana Okafor | dana@okaforcoffee.co | East |
{ "CustomerId": "c-014", // partition key — one customer "Name": "Dana Okafor", "Email": "dana@okaforcoffee.co", "Region": "East"} That one key, CustomerId, is this table's partition key — give DynamoDB a CustomerId and it goes straight to that customer. Orders are different: a customer has many, so an Orders table uses a composite key — a partition key that groups, plus a sort key that orders within the group:
| CustomerId PK partition key — whose orders | OrderDate SK sort key — a full timestamp, unique within the customer and still date-sortable | OrderId | Total a number, not "$540" (store integer cents in production) — so filters like Total > 100 compare numerically | Status |
|---|---|---|---|---|
| c-014 | 2026-06-19T14:03:00Z | Order#8842 | 540 | delivered |
{ "CustomerId": "c-014", // partition key — whose orders "OrderDate": "2026-06-19T14:03:00Z", // sort key — a full timestamp, unique within the customer and still date-sortable "OrderId": "Order#8842", "Total": 540, // a number, not "$540" (store integer cents in production) — so filters like Total > 100 compare numerically "Status": "delivered"} CustomerId (the partition key) keeps all of one customer's orders in the same place; OrderDate (the sort key) lines them up in time, so you can ask for all of a customer's orders or just the ones since June. A sort key has to be unique within its partition, which is why it's a full timestamp and not a bare 2026-06-19 — otherwise a customer's second order that day would overwrite the first (if two can share an instant, append the order id: OrderDate#OrderId). Two tables, one shape each — this is multi-table design, and it maps closely to how you'd model the same store in a relational database. Hold onto it; there's an alternative worth meeting later.
Query is the happy path
-- Targeted: you know the partition key, so DynamoDB goes to one customer.SELECT * FROM "Orders"WHERE CustomerId = 'c-014'-- Broad: Region isn't a key, so DynamoDB reads the whole table, then filters.SELECT * FROM "Customers"WHERE Region = 'West' A Query starts with a partition-key value — a CustomerId you already know. It can optionally narrow within that partition using the sort key. That is DynamoDB at its best: predictable, direct, and cheap enough to reason about.
A Scan reads — and bills you for — every item in a table or index, then filters out the rows you did not want. "Everyone in the West region" has no key to start from, so it reads every customer and keeps the matches. The filtered-away rows are still read and still paid for, so "the query only returned three rows" tells you nothing about the cost. DynoStudio warns when a statement becomes a Scan because that is usually the first place a beginner accidentally turns a small test query into an expensive production habit.
That doesn't make a Scan forbidden — reading a small lookup table, exporting once, or running a deliberate full-table job are all fine reasons to read everything. The warning is about doing it by accident, at scale.
Feel the rule before you memorise it. Flip the predicates below and watch the statement, the "Executes as" strip, and the bill respond — the partition-key equality is the only toggle that changes what DynamoDB reads:
SELECT CustomerId, OrderDate, Status FROM "Orders"
WHERE CustomerId = 'c-014'Indexes are alternate doors
-- Base table: get one customer's orders by CustomerId.SELECT * FROM "Orders"WHERE CustomerId = 'c-014'-- A GSI reads the SAME orders by a different key — here by Status, so-- "every order still processing" is a Query, not a Scan.SELECT * FROM "Orders"."ByStatus"WHERE Status = 'processing'-- ...but a GSI obeys the same rule: filter it on a non-key attribute,-- and it is still a Scan, just of the index.SELECT * FROM "Orders"."ByStatus"WHERE Total > 100 A global secondary index, or GSI, is another key shape for the same table. If the Orders table is keyed by CustomerId, a GSI can let you read the same orders by Status — so "every order still processing", across all customers, becomes a Query instead of a Scan. DynoStudio shows indexes as explicit targets so you can see whether a statement reads the base table or an alternate access path.
A GSI obeys the same Query/Scan rule as the base table — an equality on the GSI's partition key is a Query, but filtering a GSI on a non-key attribute is still a Scan, just of the index. An index gives you a new cheap key to read by, not a free pass: the way to fix a Scan is usually a GSI whose partition key is the attribute you're filtering on.
Access patterns come first
Relational modeling often starts with normalized entities, then writes queries later. DynamoDB modeling usually starts with the questions the app must answer: "get a customer by id", "list a customer's orders", "find every order still processing". The table and index keys are designed so those questions become Queries instead of Scans.
Those questions drive a bigger decision, too — not just how to key each table, but how many tables to use at all.
One table, or many?
Everything so far used a table per entity — a Customers table, an Orders table. That is multi-table design, and it is the most familiar: it looks like SQL, each table has one clear shape, and it is easy to reason about. The tradeoff is that answering "this customer and all her orders" means reading two tables and stitching the results together.
DynamoDB offers something with no SQL equivalent: put different kinds of item in the same table, keyed so related ones sit together. That is single-table design. A shared pk/sk does the work, with a prefix marking what each item is — flip to the table view and you can see it, the profile row and the order rows filling different columns in the one table:
| pk PK | sk SK | Name | Total |
|---|---|---|---|
| CUSTOMER#c-014 | PROFILE | Dana Okafor | — |
| CUSTOMER#c-014 | ORDER#8842 | — | 540 |
| CUSTOMER#c-014 | ORDER#9013 | — | 360 |
[ { "pk": "CUSTOMER#c-014", "sk": "PROFILE", "Name": "Dana Okafor" }, { "pk": "CUSTOMER#c-014", "sk": "ORDER#8842", "Total": 540 }, { "pk": "CUSTOMER#c-014", "sk": "ORDER#9013", "Total": 360 }] Now a single Query on pk = 'CUSTOMER#c-014' returns the customer and every one of her orders together — one query path, not a two-table stitch — because they share a partition. That is the payoff: fewer round-trips for data you always read together. (A Query still pages at 1 MB, so a very large history comes back over several pages; it's the same key, not a new access pattern.) The price is the modeling — you design those # key shapes up front, around your access patterns, and the table no longer has one tidy schema.
There is also a second reason to reach for many tables, and it has nothing to do with access patterns — it is about who owns the data. When a system is split into services, each service usually keeps its own table: a login service owns a users table, an orders service owns an orders table, so the two can deploy, scale, and be secured on their own. Sharing one table across services would tie them back together. (Where the orders service needs a buyer's name, it references them by id or keeps a small synced copy — the buyer's home stays with the login service.)
So the honest answer is often both: single-table within a service where items are read together, and separate tables across services that own different data. DynoStudio doesn't push either — it reads, queries, and prices whichever you chose. Modeling across services goes deeper on that choice.
The Bean & Bark tutorial is single-table end to end because it is one store — one service's data — so co-locating a customer with her orders (the CUSTOMER#/ORDER# shape above) is exactly right, and reading her history starts from one cheap Query path. Grow it into a platform with separate identity and orders services, and you'd spread across several tables instead.
Where DynoStudio fits
DynoStudio does not replace DynamoDB concepts. It makes them visible while you work: the "Executes as" strip shows the native request, key-aware statements tint as targeted reads, Scan-shaped statements warn before they run, and schema-derived functions make common access patterns repeatable. When you're connected, the Scan warning is even quantified against the table's live item count — it estimates how many items the read will bill, not just that it's a Scan.
Once those ideas make sense, move into the PartiQL dialect overview. First queries and Targeted reads stay beginner-friendly; the joins, aggregates, and transaction pages are references you can return to when your data model needs them.
- Core components of Amazon DynamoDB AWS docs: tables, items, attributes, primary keys, and secondary indexes.
- Querying tables in DynamoDB AWS docs: the key-based read path DynoStudio tries to keep visible.
- Scanning tables in DynamoDB AWS docs: why filters after a Scan do not reduce the read cost.
- Single-table vs. multi-table design in Amazon DynamoDB AWS Database Blog: when single-table design helps, and when multiple tables may be clearer.
- Creating a single-table design with Amazon DynamoDB AWS Compute Blog: a practical access-pattern-first walkthrough of the single-table approach.
- A table holds items; the partition key picks the shelf, the sort key orders it.
- A Query goes straight to one partition; a Scan walks — and bills for — everything, even rows a filter throws away. DynoStudio flags Scans before they run.
- A GSI is an alternate key shape for the same table, shown as an explicit target — and it's Query-or-Scan by the same rule.
- Design keys around the questions your app asks, so those questions become Queries, not Scans.
- Multi-table design (a table per entity) is the familiar start; single-table design (many entity types in one table, keyed by prefix) trades modeling effort for fewer requests on related data.
SELECT * FROM "Customers" WHERE Region = 'West' returns 3 rows. How many items does DynamoDB bill you for reading?Orders by Status, which isn't a key. What's the right fix?