# 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.

**You'll learn**

- 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):

_Interactive on the web page: the clickable library-shelf diagram (Query / Scan / GSI). https://www.kanject.com/docs/dynostudio-dynamodb-basics/_

## 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:

**`Customers`** — the same DynamoDB item(s) as a table, then the stored JSON:

| CustomerId _(PK)_ | Name | Email | Region |
| --- | --- | --- | --- |
| c-014 | Dana Okafor | dana@okaforcoffee.co | East |

```json
{
  "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:

**`Orders`** — the same DynamoDB item(s) as a table, then the stored JSON:

| CustomerId _(PK)_ | OrderDate _(SK)_ | OrderId | Total | Status |
| --- | --- | --- | --- | --- |
| c-014 | 2026-06-19T14:03:00Z | Order#8842 | 540 | delivered |

```json
{
  "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

```sql
-- 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.

> **Pitfall:** The one cost idea everything else builds on: on DynamoDB you pay for what is **read**, not what is **returned**. A Scan reads the whole table — so a filter that returns three rows out of three million still bills for all three million. Keeping reads to Queries (a partition-key equality) is how you keep the bill proportional to the answer.

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*:

_Interactive on the web page: the query-shape explorer — toggle predicates and watch the read classify as Query or Scan. https://www.kanject.com/docs/dynostudio-dynamodb-basics/_

## Indexes are alternate doors

```sql
-- 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.

> **Note:** One thing the toy `ByStatus` index glosses over: a GSI's partition key needs **enough distinct values to spread load**. `Status` has only a handful, so at scale every "processing" order would pile onto one partition — a hot key. Production designs combine it with another attribute or add a shard suffix (`Status#0`, `Status#1`, …). The lesson here is the *alternate read path*; a real `ByStatus` key would usually be compound.

## 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:

**`BeanAndBark`** — the same DynamoDB item(s) as a table, then the stored JSON:

| pk _(PK)_ | sk _(SK)_ | Name | Total |
| --- | --- | --- | --- |
| CUSTOMER#c-014 | PROFILE | Dana Okafor | — |
| CUSTOMER#c-014 | ORDER#8842 | — | 540 |
| CUSTOMER#c-014 | ORDER#9013 | — | 360 |

```json
[
  { "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.

> **Neither is “correct”:** **Multi-table** is simpler and often plenty. **Single-table** trades modeling effort for fewer requests when you constantly read related items together. A fair rule of thumb: start multi-table, and reach for single-table when your access patterns are stable and the extra round-trips start to hurt. The two AWS write-ups below walk the tradeoff in depth.

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](https://www.kanject.com/docs/dynostudio-multi-table-services/) goes deeper on that choice.

The [Bean & Bark tutorial](https://www.kanject.com/docs/dynostudio-bean-bark/) 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](https://www.kanject.com/docs/dynostudio-partiql/). [First queries](https://www.kanject.com/docs/dynostudio-partiql-basics/) and [Targeted reads](https://www.kanject.com/docs/dynostudio-partiql-reads/) stay beginner-friendly; the joins, aggregates, and transaction pages are references you can return to when your data model needs them.

**AWS reading path**

- [Core components of Amazon DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html) — AWS docs: tables, items, attributes, primary keys, and secondary indexes.
- [Querying tables in DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html) — AWS docs: the key-based read path DynoStudio tries to keep visible.
- [Scanning tables in DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html) — AWS docs: why filters after a Scan do not reduce the read cost.
- [Single-table vs. multi-table design in Amazon DynamoDB](https://aws.amazon.com/blogs/database/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](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/) — AWS Compute Blog: a practical access-pattern-first walkthrough of the single-table approach.

**Recap**

- 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.

**Check yourself**

**1. A table holds three million items. `SELECT * FROM "Customers" WHERE Region = 'West'` returns 3 rows. How many items does DynamoDB bill you for reading?**

- All three million — the filter runs after a full-table Scan ✓ — `Region` isn't a key, so this is a Scan: every item is read — and billed — and *then* the filter throws away what didn't match.
- 3 — you pay for what comes back — you pay for what is **read**, not what is *returned*. The filter runs after the read, so the discarded rows are still billed.
- None — filtering is free — the filter itself is free, but the *read* it runs on isn't — and here the read is the whole table.

**2. What turns a read into a cheap, targeted **Query** instead of a Scan?**

- A partition-key equality in the `WHERE` ✓ — the partition key tells DynamoDB exactly which shelf to visit. Everything else walks the whole library.
- Adding a `LIMIT` — a `LIMIT` bounds the page, but a Scan with a `LIMIT` is still a Scan — it just stops walking earlier.
- Selecting fewer columns — projection changes what comes *back*, not what is *read* — the bill follows the read.

**3. You keep filtering `Orders` by `Status`, which isn't a key. What's the right fix?**

- Add an **index** keyed for `Status` — usually compound or sharded at scale ✓ — a GSI is an alternate set of shelf labels, so reading `"Orders"."ByStatus"` makes the status filter a key condition. Keyed on `Status` alone it's a hot-key toy; a real one folds in another attribute or a shard suffix (see the note above).
- Make the Scan smaller with `LIMIT` — the read is still untargeted — `LIMIT` trims the page, not the approach.
- Ask for fewer attributes per item — that shrinks the payload, not the number of items read — the Scan still walks every shelf.

**4. You constantly need a customer **and** all their orders together. Which design makes that a single Query, rather than stitching two tables?**

- Single-table — customers and orders in one table, sharing a partition key ✓ — items that share a `pk` come back in one Query, so a customer and her orders arrive together — that's single-table design's main payoff.
- Multi-table with a GSI on each table — a GSI adds a read path *within* a table, but a customer in one table and orders in another still means two reads to stitch together.
- Neither — DynamoDB can't return two kinds of item at once — it can, when they share a partition in one table — that's exactly what single-table design is for.

> **Next step:** Prefer to learn by doing? The [Bean & Bark tutorial](https://www.kanject.com/docs/dynostudio-bean-bark/) applies all of this to one real dataset. Prefer syntax first? Read [PartiQL: first queries](https://www.kanject.com/docs/dynostudio-partiql-basics/), or [targeted reads](https://www.kanject.com/docs/dynostudio-partiql-reads/) if you already know SQL and want the DynamoDB cost model to click.

---
_Source: https://www.kanject.com/docs/dynostudio-dynamodb-basics/ · Kanject Docs_
