I avoided single-table design for years. Every time I read one of the canonical posts, I'd nod along, open my editor, and then quietly create three tables because reasoning about overloaded keys made my head hurt. After shipping two production systems with it, I've made peace with the pattern, but only after admitting which parts of the hype are real and which parts cost you more than they save.

This is the post I wish I'd had: the practitioner's version, written for someone who finds the idea genuinely annoying.

Why it exists at all

DynamoDB charges you per request and gives you single-digit millisecond latency only when you don't make the database do work. There are no server-side joins. So if your access pattern is "load an order and all of its line items in one shot," the cheapest way to serve that is to physically co-locate the order and its items under the same partition key. That's the entire idea. Single-table design is not an aesthetic, it's a way to pre-join data at write time so reads are one Query instead of N round trips.

If your app does one or two entity lookups by ID and never needs to fetch related items together, you do not need single-table design. Use multiple tables and stop feeling guilty.

The mental model that finally worked

Stop thinking "table of rows." Think "a sorted list of items, partitioned by PK, ordered by SK." You name your keys generically (PK, SK) because different entity types share them. The discipline is: write down every access pattern first, then design keys to satisfy each one with a single Query.

Here's a customer-and-orders model. One partition holds the customer profile plus every order, so "get customer with recent orders" is one call:

PK                 SK                  Type      Attributes
CUST#a1b2          PROFILE             Customer  name, email, tier
CUST#a1b2          ORDER#2026-06-01#9  Order     total=84.20, status=SHIPPED
CUST#a1b2          ORDER#2026-06-03#4  Order     total=12.00, status=PENDING

Because SK values sort lexicographically, a begins_with(SK, "ORDER#") with ScanIndexForward=false returns newest orders first, no extra index needed.

Querying it without losing your mind

The boto3 call is unremarkable once the keys are right, that's the point:

import boto3
from boto3.dynamodb.conditions import Key

table = boto3.resource("dynamodb").Table("app")

resp = table.query(
    KeyConditionExpression=Key("PK").eq("CUST#a1b2")
        & Key("SK").begins_with("ORDER#"),
    ScanIndexForward=False,
    Limit=10,
)
orders = resp["Items"]

One request, one partition, predictable cost. The trade-off is that the write side now owns the complexity: your application code is responsible for stamping the right PK/SK and a Type attribute on every item.

Inverted indexes for the "other direction"

The moment you need to query orders by status across all customers, your primary key is useless, status isn't in it. This is where a Global Secondary Index earns its keep. Create a GSI that flips or re-keys the data:

Access patternIndexKey condition
Orders for a customerBase tablePK = CUST#id
All pending ordersGSI1GSI1PK = STATUS#PENDING
Order by IDGSI2GSI2PK = ORDER#id

You add GSI1PK/GSI1SK attributes only to items that participate in that index. DynamoDB skips items missing the index key entirely (sparse indexes), which keeps the GSI small and cheap.

Where it genuinely hurts

I won't pretend it's free. The real costs I've paid:

  • Migrations are brutal. Changing a key format means rewriting items, because keys are immutable. Budget for a backfill job from the start.
  • Analytics are awkward. A table full of overloaded keys is hostile to ad-hoc querying. I stream changes to S3 via DynamoDB Streams + Kinesis Firehose and run Athena there instead.
  • Onboarding tax. A new engineer sees PK/SK and has no idea what's in the table. Document the access patterns in the repo, not in someone's head.
  • 40KB item / 10GB partition limits are real. Unbounded "all items under one customer" collections will eventually bite you.

Takeaways

  • Single-table design pre-joins data at write time so related items load in one Query, adopt it only when you actually fetch related items together.
  • Model access patterns first, then design generic PK/SK keys to satisfy each one; reach for sparse GSIs for the inverse directions.
  • The cost is paid in migrations, analytics, and onboarding, offload analytics to S3/Athena via Streams.
  • If your reads are simple ID lookups, multiple tables are fine and far easier to maintain.