Skip to content

Composite Key

Composite key means two different things in DynamoDB, and the ambiguity causes real confusion. Both are worth knowing because both appear constantly in schema discussions.

1. A composite primary key is a primary key made of two attributes: a partition key and a sort key. This is AWS’s official usage. The alternative is a simple primary key, which is a partition key alone.

2. A composite key attribute is a single key whose value concatenates several logical values, like ORDER#01HVMK#ITEM#prod_abc. This is the community usage, and it’s the one that matters more in practice for single-table design.

Composite primary keys

When you create a table you choose one of two key schemas:

Key schemaStructureBehaviour
SimplePartition key onlyOne item per partition key value. GetItem only.
CompositePartition key + sort keyMany items per partition key value, ordered by sort key. Query available.

A composite primary key is what makes a partition able to hold more than one item, which is what makes co-location and single-table design possible. If you’re doing anything beyond a key-value store, you want a composite primary key.

The choice is permanent. Key schema cannot be altered after table creation.

Composite key attributes

This is where the design work happens. A composite attribute packs several values into one key so that a single sort key can serve multiple query granularities.

PK: TENANT#t_01
SK: PROJECT#01HVMK3P2Q#TASK#01HVNR4Q3R

Because sort keys order lexicographically, the concatenation encodes a hierarchy that can be queried at any level from the left:

sk begins_with "PROJECT#"                        → every project and task
sk begins_with "PROJECT#01HVMK3P2Q"              → one project and its tasks
sk begins_with "PROJECT#01HVMK3P2Q#TASK#"        → just that project's tasks
sk = "PROJECT#01HVMK3P2Q#TASK#01HVNR4Q3R"        → one specific task

Four access patterns, one key structure, no GSI.

Ordering rules

The order of components is the whole design, and getting it wrong is a migration.

Most general to most specific, left to right. You can filter on a prefix. You can never filter on a suffix. COUNTRY#STATE#CITY lets you query a country, a state, or a city. CITY#STATE#COUNTRY lets you query only by city, and only if you know it exactly.

Fixed-length components first. Variable-length values in the middle produce unpredictable prefix boundaries. Put them last.

Every component before the one you filter on must be known. To query PROJECT#<id>#TASK#, you need the project ID. If a caller has only the task ID, this structure can’t serve them, and you need a second access path.

Delimiter choice

The convention is #, for two reasons.

It sorts early in UTF-8 byte order, before digits and letters. This makes #METADATA reliably the first item in a partition, and makes sk begins_with "#" a clean way to fetch singleton records.

It rarely appears in identifiers, so collisions are unlikely. But unlikely is not impossible: if a user-supplied value can contain #, parsing breaks and prefixes can collide across entity types. Either sanitise on write, or restrict composite components to system-generated identifiers.

Some teams use | or ~. Both work. # is more common and more readable in the console, which matters when you’re debugging a schema you didn’t write.

ElectroDB composites

ElectroDB models composite attributes explicitly, which removes the string concatenation from your application code:

indexes: {
  primary: {
    pk: {
      field: "pk",
      composite: ["tenantId"],
      template: "TENANT#${tenantId}",
    },
    sk: {
      field: "sk",
      composite: ["projectId", "taskId"],
      template: "PROJECT#${projectId}#TASK#${taskId}",
    },
  },
}

ElectroDB then generates typed query methods for each valid prefix. Supplying only projectId produces a begins_with query; supplying both produces an exact match. The library enforces left-to-right ordering at the type level, which catches the most common composite key mistake at compile time rather than in production.

Common mistakes

Ordering components by importance instead of by query granularity. The order should be dictated by what callers know at query time, not by which field feels most significant.

Including a mutable value. Keys are immutable. If a component can change, updating it means delete-and-recreate, and any concurrent reader may miss the item entirely. Never compose keys from names, emails, or statuses that change.

Composing keys that duplicate data unnecessarily. If projectId is a ULID, the creation timestamp is already inside it. PROJECT#<createdAt>#<projectId> adds a component that provides no ordering benefit and blocks direct lookup.

Parsing keys in application code. If you’re splitting key strings on # to recover values, store those values as separate attributes instead. Keys are for access, attributes are for data.

Assuming composite keys work on partition keys the same way. They don’t. A composite value in a partition key still requires an exact match, so pk: TENANT#t_01#PROJECT#p_01 cannot be prefix-queried. Hierarchies belong in the sort key.


Composite key ordering is one of the decisions that’s cheap to change on a whiteboard and expensive to change in production. I’m building singletable.dev to make key structure visible before it’s deployed.

← All glossary terms