{
    "componentChunkName": "component---src-templates-mdx-content-page-js",
    "path": "/dynamodb-single-table-design-guide/",
    "result": {"data":{"mdx":{"body":"var _excluded = [\"components\"];\n\nfunction _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }\n\nfunction _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }\n\nfunction _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }\n\n/* @jsxRuntime classic */\n\n/* @jsx mdx */\nvar _frontmatter = {\n  \"path\": \"/dynamodb-single-table-design-guide/\",\n  \"publishedDate\": \"2026-10-23\",\n  \"updatedDate\": \"2026-10-23\",\n  \"metaTitle\": \"DynamoDB Single-Table Design: Patterns and Worked Model\",\n  \"metaDescription\": \"Design a DynamoDB single-table model with 10 tested queries, many-to-many relationships, native multi-attribute GSI keys, and clear trade-offs.\",\n  \"title\": \"DynamoDB single-table design: a practical guide for 2026\",\n  \"authorName\": \"Rafal Wilinski\",\n  \"authorLink\": \"https://x.com/rafalwilinski\",\n  \"authorImage\": \"/images/authors/rafal-wilinski.jpg\",\n  \"image\": \"/images/posts/dynamodb-single-table-design-guide/social.png\"\n};\nvar layoutProps = {\n  _frontmatter: _frontmatter\n};\nvar MDXLayout = \"wrapper\";\nreturn function MDXContent(_ref) {\n  var components = _ref.components,\n      props = _objectWithoutProperties(_ref, _excluded);\n\n  return mdx(MDXLayout, _extends({}, layoutProps, props, {\n    components: components,\n    mdxType: \"MDXLayout\"\n  }), mdx(\"p\", null, \"DynamoDB single-table design stores several entity types in one table and groups related items so one \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"Query\"), \" can retrieve them together. You design the keys around application access patterns, such as loading a project with its members and tasks, rather than creating a table for each entity. It fits predictable operational queries; changing reports and ad hoc joins often need a different design. Since November 2025, GSIs can also use multiple native attributes as their keys, reducing the need for concatenated index-key strings.\"), mdx(\"p\", null, \"The worked model below has ten access patterns, twelve seed items, complete AWS SDK for JavaScript v3 code, and Python equivalents for three queries. All ten patterns, including native multi-attribute GSIs and an index added after loading data, were run against DynamoDB Local 3.3.2 on October 23, 2026.\"), mdx(\"h2\", null, \"What does single-table design actually buy you?\"), mdx(\"p\", null, \"A relational project screen might join projects, memberships, and tasks. DynamoDB does not execute that join. Instead, put the records needed together under the same partition-key value and distinguish them with sort keys. A query returns heterogeneous items; your application separates them by an \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"entity\"), \" attribute. AWS's \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/\"\n  }, \"single-table walkthrough\"), \" describes this access-pattern-first approach.\"), mdx(\"p\", null, \"This reduces application requests for that screen. It does not make arbitrary relationships queryable, enforce foreign keys, or turn a query into an atomic snapshot. A large collection requires pagination. The \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-joins/\"\n  }, \"DynamoDB joins guide\"), \" explains alternatives when related data cannot be colocated.\"), mdx(\"p\", null, \"\\\"Single table\\\" is normally a design choice within a service or bounded business domain. It need not mean putting billing, telemetry, authentication, and every other service into one company-wide table.\"), mdx(\"h2\", null, \"Single-table design vs. multiple tables: when should you use each?\"), mdx(\"p\", null, \"Use the following as a decision framework, then test the actual requests your application makes.\"), mdx(\"table\", null, mdx(\"thead\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"thead\"\n  }, mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Your workload\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Starting choice\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Reason\"))), mdx(\"tbody\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Stable screens repeatedly load several related entity types\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Single table\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"An item collection can answer the screen's request directly\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Entities are mostly fetched independently\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Multiple tables, or a simple shared table\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Complex overloading adds little benefit\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"GraphQL uses independent resolvers per entity\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Often multiple tables\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Colocation helps only if resolvers actually share or batch the read\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Different services own different entities and deployments\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Separate service tables\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Clear ownership and independent operational settings\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Rapidly changing filters, joins, and reporting requirements\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Relational or analytical store\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"More DynamoDB tables do not create ad hoc query capability\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Predictable operational reads plus occasional analytics\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"DynamoDB plus an analytical copy\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Keep reporting work outside the application's request path\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Team is learning DynamoDB\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Start with a small model\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Prove key queries and write consistency before adding indexes\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Different backup, encryption, or replication requirements\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Usually multiple tables\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"These boundaries are not defined by entity prefixes\")))), mdx(\"p\", null, \"GraphQL itself is not an argument against single-table design. A resolver that loads a whole project view can benefit; unrelated field resolvers that each issue their own request will not automatically do so. Similarly, using multiple tables is not a failure to understand DynamoDB. Choose fewer requests only when the maintenance cost is justified.\"), mdx(\"h2\", null, \"How to design a DynamoDB data model\"), mdx(\"ol\", null, mdx(\"li\", {\n    parentName: \"ol\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"List entities and access patterns.\"), \" For each request, write its input keys, output fields, order, page size, consistency requirement, and expected traffic. Include writes, deletes, and uniqueness rules. \\\"Search projects\\\" is too vague; \\\"list projects for tenant t1 in project-ID order\\\" is implementable.\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Choose the base primary key.\"), \" A base table has one \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"/dynamodb-partition-key/\"\n  }, \"partition-key attribute\"), \" and optionally one \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"/dynamodb-sort-key/\"\n  }, \"sort-key attribute\"), \". A \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"/dynamodb-composite-key/\"\n  }, \"composite primary key\"), \" identifies an item by the pair, not by the partition key alone. Prefixes such as \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"PROJECT#\"), \" are application conventions, not DynamoDB operators.\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Build item collections.\"), \" Group items that are read together under one partition-key value. Separate large independent collections: our tenant's project directory comes from an index, while each project's tasks live under its own key. An item collection is a logical group, not a promise that every item stays on one physical partition.\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Add only the indexes the remaining patterns require.\"), \" An overloaded GSI serves more than one entity type; an inverted index reverses a relationship's lookup direction. Record its projection, consistency, and write cost. The \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"/dynamodb-gsi/\"\n  }, \"GSI guide\"), \" and \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"/dynamodb-indexes/\"\n  }, \"index comparison\"), \" cover the operational choices.\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Validate with data and executable queries.\"), \" Include empty results, multiple members, several statuses, tied timestamps, and enough items to exercise pagination. Verify every required field is available without an unplanned follow-up request. DynamoDB \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"/dynamodb-keys/\"\n  }, \"key terminology\"), \" is useful when reviewing the resulting model with the team.\")), mdx(\"p\", null, mdx(\"img\", {\n    parentName: \"p\",\n    \"src\": \"/images/posts/dynamodb-single-table-design-guide/std-access-patterns.svg\",\n    \"alt\": \"Ten access patterns map to the base table, an overloaded relationship GSI, a native task-board GSI, and a later sparse escalation GSI.\"\n  })), mdx(\"h2\", null, \"Worked example: a project-management SaaS\"), mdx(\"p\", null, \"A tenant has projects and users. A user can belong to several projects, and a project can have several users. Each project has tasks. Membership stores a role; it does not duplicate the user's profile. An escalation assigns a task to a responder.\"), mdx(\"p\", null, \"Every example belongs to tenant \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"t1\"), \". Tenant-aware keys prevent accidental identifier collisions, but the application must still authorize tenant and project access. Never accept a client-supplied tenant key as proof of permission.\"), mdx(\"h3\", null, \"Ten access patterns before writing keys\"), mdx(\"p\", null, \"The base-table reads below request strong consistency. GSI reads are eventually consistent. Each list needs a cursor in a production API; \\\"all\\\" means following every page, not an unbounded HTTP response.\"), mdx(\"table\", null, mdx(\"thead\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"thead\"\n  }, mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"ID\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Request and known input\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Key or index\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Result order\"))), mdx(\"tbody\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Tenant profile by tenant ID\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Base: tenant \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PK\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"SK = META\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"One item\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Projects belonging to a tenant\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Relationships: tenant \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"GSI1PK\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PROJECT#\"), \" prefix\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Project ID\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP3\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Project details, memberships, and tasks\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Base: project \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PK\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"SK, with entity prefixes\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP4\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Members of a project\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Base: project \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PK\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"MEMBER#\"), \" prefix\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"User ID\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP5\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Tasks in a project\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Base: project \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PK\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"TASK#\"), \" prefix\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Creation time, then task ID\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP6\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Projects a user belongs to\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Relationships: user \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"GSI1PK\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PROJECT#\"), \" prefix\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Project ID; membership records\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP7\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"User profile by tenant and user ID\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Base: user \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PK\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"SK = META\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"One item\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP8\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Open tasks in a project within a date range\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"TaskBoard: tenant + project, status + creation range\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Creation time, then task ID\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP9\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Latest two tasks in a project, any status\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Base: project \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"PK\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"TASK#\"), \" prefix, reverse\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Newest first\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"AP10\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Escalations assigned to a tenant's responder\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Escalations: tenant + responder\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Creation time, then task ID\")))), mdx(\"p\", null, \"AP4 and AP6 return relationship records with IDs and roles. If a screen also needs current user names or project names, fetch the corresponding metadata items, or deliberately duplicate those display fields and define how updates propagate. The access pattern must specify which contract it needs.\"), mdx(\"h3\", null, \"Full seed-item layout\"), mdx(\"p\", null, \"These abbreviations keep the table readable: \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"T = TENANT#t1\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"P1 = TENANT#t1#PROJECT#p1\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"P2 = TENANT#t1#PROJECT#p2\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"U1 = TENANT#t1#USER#u1\"), \", and \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"U2 = TENANT#t1#USER#u2\"), \". \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"D20\"), \" through \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"D23\"), \" mean \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"2026-10-20T09:00:00Z\"), \" through \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"2026-10-23T09:00:00Z\"), \". A dash means the attribute is absent, never an empty key string.\"), mdx(\"p\", null, \"Every row also has \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"tenantId = t1\"), \", its shown \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"entity\"), \", and an \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"id\"), \" equal to the identifier in the first column. Task rows additionally carry \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"taskId\"), \" equal to \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"id\"), \". The setup code expands every abbreviation into the actual stored value.\"), mdx(\"table\", null, mdx(\"thead\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"thead\"\n  }, mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Entity and id\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"PK\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"SK\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"GSI1PK\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"GSI1SK\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Other attributes\"))), mdx(\"tbody\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Tenant \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"t1\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"T\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"META\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"name: Acme\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"User \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"u1\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"U1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"META\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"name: Ada\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"User \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"u2\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"U2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"META\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"name: Ben\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Project \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"p1\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"META\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"T\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"PROJECT#p1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p1, name: Launch\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Project \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"p2\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"META\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"T\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"PROJECT#p2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p2, name: Docs\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Member \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"p1/u1\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"MEMBER#u1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"U1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"PROJECT#p1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p1, userId: u1, role: owner\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Member \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"p1/u2\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"MEMBER#u2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"U2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"PROJECT#p1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p1, userId: u2, role: editor\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Member \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"p2/u1\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"MEMBER#u1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"U1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"PROJECT#p2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p2, userId: u1, role: owner\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Task \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"a1\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"TASK#D20#a1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p1, status: OPEN, createdAt: D20, title: Write copy\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Task \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"a2\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"TASK#D21#a2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p1, status: DONE, createdAt: D21, title: Choose logo\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Task \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"a3\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P1\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"TASK#D22#a3\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p1, status: OPEN, createdAt: D22, title: Fix billing, escalatedTo: u2\"))), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Task \", mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"a4\")), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"P2\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"TASK#D23#a4\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"-\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"projectId: p2, status: OPEN, createdAt: D23, title: API docs\"))))), mdx(\"p\", null, \"The native indexes use these existing attributes, with no \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI2PK\"), \" or \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI3PK\"), \" strings:\"), mdx(\"table\", null, mdx(\"thead\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"thead\"\n  }, mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Index\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Partition-key attributes\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Sort-key attributes, in order\"), mdx(\"th\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Seed items indexed\"))), mdx(\"tbody\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Relationships\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"GSI1PK\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"GSI1SK\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Two projects and three memberships\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"TaskBoard\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"tenantId, projectId\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"status, createdAt, taskId\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"a1, a2, a3, a4\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"Escalations, added later\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"tenantId, escalatedTo\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"createdAt, taskId\"), mdx(\"td\", {\n    parentName: \"tr\",\n    \"align\": null\n  }, \"a3 only\")))), mdx(\"p\", null, mdx(\"img\", {\n    parentName: \"p\",\n    \"src\": \"/images/posts/dynamodb-single-table-design-guide/std-item-collection.svg\",\n    \"alt\": \"One project item collection contains two membership items, project metadata, and three separately stored tasks, ordered by sort key.\"\n  })), mdx(\"h3\", null, \"Multi-attribute GSI keys: what changed in 2025?\"), mdx(\"p\", null, \"AWS \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://aws.amazon.com/about-aws/whats-new/2025/11/amazon-dynamodb-multi-attribute-composite-keys-global-secondary-indexes/\"\n  }, \"introduced multi-attribute GSI keys on November 19, 2025\"), \". A GSI can have up to four partition-key attributes and four sort-key attributes. \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"The base-table key schema is unchanged.\")), mdx(\"p\", null, \"Previously, TaskBoard would commonly need synthetic fields such as \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI2PK = TENANT#t1#PROJECT#p1\"), \" and \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI2SK = OPEN#2026-10-20T09:00:00Z#a1\"), \". The native version indexes \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"tenantId\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"projectId\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"status\"), \", \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"createdAt\"), \", and \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"taskId\"), \" directly. Updating \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"status\"), \" changes the index placement without application code rebuilding \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI2SK\"), \".\"), mdx(\"p\", null, \"The \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.DesignPattern.MultiAttributeKeys.html\"\n  }, \"query rules\"), \" still constrain access: specify equality for every partition component, then sort components from left to right. A range or \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"begins_with\"), \" condition must be last. AP8 fixes \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"status\"), \" before ranging over \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"createdAt\"), \"; you cannot skip status to get all tasks by time. AP9 therefore uses the base table's chronological task keys.\"), mdx(\"p\", null, \"Native keys remove string-encoding work for suitable indexes. They do not remove access-pattern design, eventual consistency, or index writes. Synthetic keys remain useful for overloaded indexes like Relationships, where a common key attribute deliberately represents different entity relationships.\"), mdx(\"h2\", null, \"Run the complete model with SDK v3\"), mdx(\"p\", null, \"Use Node.js 22 or 24, Docker, and curl. Run this in a new scratch directory, then save the following files there. DynamoDB Local \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocalHistory.html\"\n  }, \"added multi-attribute keys in 3.3.0\"), \"; the tested image was 3.3.2. The examples explicitly use dummy credentials and localhost.\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-bash\"\n  }, \"npm init -y\\nnpm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb\\ndocker run -d --rm --name dynamodb-local -p 8000:8000 \\\\\\n  amazon/dynamodb-local:latest -jar DynamoDBLocal.jar \\\\\\n  -inMemory -sharedDb -disableTelemetry\\n# Local returns an HTTP error for this unsigned request once it is ready.\\nddb_ready=false\\nfor attempt in {1..30}; do\\n  if curl --connect-timeout 1 --max-time 2 -s -o /dev/null http://localhost:8000; then\\n    ddb_ready=true\\n    break\\n  fi\\n  sleep 1\\ndone\\ntest \\\"$ddb_ready\\\" = true\\n\")), mdx(\"h3\", null, \"Shared client and pagination: client.mjs\"), mdx(\"p\", null, mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"queryAll\"), \" follows \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"LastEvaluatedKey\"), \" even if a page is empty. The optional result cap is used for AP9, which has no filter. Production endpoints should usually return a page and a continuation cursor instead of accumulating an entire collection. DynamoDB evaluates up to 1 MB per query page before filtering; see the \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html\"\n  }, \"Query API\"), \".\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-js\"\n  }, \"import { DynamoDBClient } from \\\"@aws-sdk/client-dynamodb\\\"\\nimport { DynamoDBDocumentClient, QueryCommand } from \\\"@aws-sdk/lib-dynamodb\\\"\\n\\nexport const TableName = \\\"SingleTableGuide\\\"\\nexport const raw = new DynamoDBClient({\\n  endpoint: \\\"http://localhost:8000\\\",\\n  region: \\\"us-east-1\\\",\\n  credentials: { accessKeyId: \\\"fake\\\", secretAccessKey: \\\"fake\\\" },\\n})\\nexport const db = DynamoDBDocumentClient.from(raw)\\n\\nexport async function queryAll(params, maxItems = Infinity) {\\n  const items = []\\n  let cursor\\n  do {\\n    const page = await db.send(new QueryCommand({\\n      TableName,\\n      ...params,\\n      ...(Number.isFinite(maxItems) ? { Limit: maxItems - items.length } : {}),\\n      ...(cursor ? { ExclusiveStartKey: cursor } : {}),\\n    }))\\n    items.push(...(page.Items ?? []))\\n    cursor = page.LastEvaluatedKey\\n  } while (cursor && items.length < maxItems)\\n  return items\\n}\\n\\nexport function show(pattern, items) {\\n  console.log(pattern, JSON.stringify(items.map(x => `${x.entity}:${x.id}`)))\\n}\\n\")), mdx(\"h3\", null, \"Create the table and seed twelve items: setup.mjs\"), mdx(\"p\", null, \"The two initial GSIs use \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"ALL\"), \" projections so every demonstrated response is self-contained. Production projections should include only what their access patterns need. \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"AttributeDefinitions\"), \" declares key attributes, not every attribute that items may contain.\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-js\"\n  }, \"import { CreateTableCommand, waitUntilTableExists } from \\\"@aws-sdk/client-dynamodb\\\"\\nimport { PutCommand } from \\\"@aws-sdk/lib-dynamodb\\\"\\nimport { raw, db, TableName } from \\\"./client.mjs\\\"\\n\\nconst key = (AttributeName, KeyType) => ({ AttributeName, KeyType })\\nawait raw.send(new CreateTableCommand({\\n  TableName,\\n  BillingMode: \\\"PAY_PER_REQUEST\\\",\\n  KeySchema: [key(\\\"PK\\\", \\\"HASH\\\"), key(\\\"SK\\\", \\\"RANGE\\\")],\\n  AttributeDefinitions: [\\n    \\\"PK\\\", \\\"SK\\\", \\\"GSI1PK\\\", \\\"GSI1SK\\\", \\\"tenantId\\\", \\\"projectId\\\",\\n    \\\"status\\\", \\\"createdAt\\\", \\\"taskId\\\",\\n  ].map(AttributeName => ({ AttributeName, AttributeType: \\\"S\\\" })),\\n  GlobalSecondaryIndexes: [\\n    {\\n      IndexName: \\\"Relationships\\\",\\n      KeySchema: [key(\\\"GSI1PK\\\", \\\"HASH\\\"), key(\\\"GSI1SK\\\", \\\"RANGE\\\")],\\n      Projection: { ProjectionType: \\\"ALL\\\" },\\n    },\\n    {\\n      IndexName: \\\"TaskBoard\\\",\\n      KeySchema: [\\n        key(\\\"tenantId\\\", \\\"HASH\\\"), key(\\\"projectId\\\", \\\"HASH\\\"),\\n        key(\\\"status\\\", \\\"RANGE\\\"), key(\\\"createdAt\\\", \\\"RANGE\\\"), key(\\\"taskId\\\", \\\"RANGE\\\"),\\n      ],\\n      Projection: { ProjectionType: \\\"ALL\\\" },\\n    },\\n  ],\\n}))\\nawait waitUntilTableExists({ client: raw, maxWaitTime: 30 }, { TableName })\\n\\nconst tenantId = \\\"t1\\\"\\nconst T = `TENANT#${tenantId}`\\nconst projectPK = id => `${T}#PROJECT#${id}`\\nconst userPK = id => `${T}#USER#${id}`\\nconst items = [\\n  { PK: T, SK: \\\"META\\\", entity: \\\"Tenant\\\", id: \\\"t1\\\", tenantId, name: \\\"Acme\\\" },\\n  ...[[\\\"u1\\\", \\\"Ada\\\"], [\\\"u2\\\", \\\"Ben\\\"]].map(([id, name]) => ({\\n    PK: userPK(id), SK: \\\"META\\\", entity: \\\"User\\\", id, tenantId, name,\\n  })),\\n  ...[[\\\"p1\\\", \\\"Launch\\\"], [\\\"p2\\\", \\\"Docs\\\"]].map(([id, name]) => ({\\n    PK: projectPK(id), SK: \\\"META\\\", entity: \\\"Project\\\", id, tenantId,\\n    projectId: id, name, GSI1PK: T, GSI1SK: `PROJECT#${id}`,\\n  })),\\n  ...[[\\\"p1\\\", \\\"u1\\\", \\\"owner\\\"], [\\\"p1\\\", \\\"u2\\\", \\\"editor\\\"], [\\\"p2\\\", \\\"u1\\\", \\\"owner\\\"]]\\n    .map(([projectId, userId, role]) => ({\\n      PK: projectPK(projectId), SK: `MEMBER#${userId}`, entity: \\\"Member\\\",\\n      id: `${projectId}/${userId}`, tenantId, projectId, userId, role,\\n      GSI1PK: userPK(userId), GSI1SK: `PROJECT#${projectId}`,\\n    })),\\n  ...[\\n    [\\\"a1\\\", \\\"p1\\\", \\\"OPEN\\\", \\\"20\\\", \\\"Write copy\\\"],\\n    [\\\"a2\\\", \\\"p1\\\", \\\"DONE\\\", \\\"21\\\", \\\"Choose logo\\\"],\\n    [\\\"a3\\\", \\\"p1\\\", \\\"OPEN\\\", \\\"22\\\", \\\"Fix billing\\\"],\\n    [\\\"a4\\\", \\\"p2\\\", \\\"OPEN\\\", \\\"23\\\", \\\"API docs\\\"],\\n  ].map(([taskId, projectId, status, day, title]) => {\\n    const createdAt = `2026-10-${day}T09:00:00Z`\\n    return {\\n      PK: projectPK(projectId), SK: `TASK#${createdAt}#${taskId}`,\\n      entity: \\\"Task\\\", id: taskId, tenantId, projectId, taskId, status, createdAt, title,\\n      ...(taskId === \\\"a3\\\" ? { escalatedTo: \\\"u2\\\" } : {}),\\n    }\\n  }),\\n]\\nfor (const Item of items) await db.send(new PutCommand({ TableName, Item }))\\nconsole.log(`Seeded ${items.length} items`)\\n\")), mdx(\"p\", null, \"Run \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"node setup.mjs\"), \" once on a fresh instance. It prints \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"Seeded 12 items\"), \". Recreating the in-memory container resets the exercise; rerunning setup against the existing table correctly fails with a table-already-exists error.\"), mdx(\"h3\", null, \"Query access patterns AP1 through AP9: queries.mjs\"), mdx(\"p\", null, \"Every call below sends \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"QueryCommand\"), \" through the shared helper. Exact-key profile requests AP1 and AP7 could instead use \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GetCommand\"), \"; they use queries here to keep the mapping explicit. The output formatter shows only entity type and identifier, while the returned items contain the attributes listed above.\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-js\"\n  }, \"import { queryAll, show } from \\\"./client.mjs\\\"\\n\\nconst T = \\\"TENANT#t1\\\"\\nconst P = `${T}#PROJECT#p1`\\nconst U = `${T}#USER#u1`\\nconst base = (PK, sk, value) => ({\\n  ConsistentRead: true,\\n  KeyConditionExpression: `PK = :pk${sk ? ` AND ${sk}` : \\\"\\\"}`,\\n  ExpressionAttributeValues: { \\\":pk\\\": PK, ...(sk ? { \\\":sk\\\": value } : {}) },\\n})\\nconst related = PK => ({\\n  IndexName: \\\"Relationships\\\",\\n  KeyConditionExpression: \\\"GSI1PK = :pk AND begins_with(GSI1SK, :prefix)\\\",\\n  ExpressionAttributeValues: { \\\":pk\\\": PK, \\\":prefix\\\": \\\"PROJECT#\\\" },\\n})\\n\\nshow(\\\"AP1\\\", await queryAll(base(T, \\\"SK = :sk\\\", \\\"META\\\")))\\nshow(\\\"AP2\\\", await queryAll(related(T)))\\nshow(\\\"AP3\\\", await queryAll(base(P)))\\nshow(\\\"AP4\\\", await queryAll(base(P, \\\"begins_with(SK, :sk)\\\", \\\"MEMBER#\\\")))\\nshow(\\\"AP5\\\", await queryAll(base(P, \\\"begins_with(SK, :sk)\\\", \\\"TASK#\\\")))\\nshow(\\\"AP6\\\", await queryAll(related(U)))\\nshow(\\\"AP7\\\", await queryAll(base(U, \\\"SK = :sk\\\", \\\"META\\\")))\\nshow(\\\"AP8\\\", await queryAll({\\n  IndexName: \\\"TaskBoard\\\",\\n  KeyConditionExpression:\\n    \\\"tenantId = :t AND projectId = :p AND #s = :s AND createdAt BETWEEN :from AND :to\\\",\\n  ExpressionAttributeNames: { \\\"#s\\\": \\\"status\\\" },\\n  ExpressionAttributeValues: {\\n    \\\":t\\\": \\\"t1\\\", \\\":p\\\": \\\"p1\\\", \\\":s\\\": \\\"OPEN\\\",\\n    \\\":from\\\": \\\"2026-10-01T00:00:00Z\\\", \\\":to\\\": \\\"2026-10-31T23:59:59Z\\\",\\n  },\\n}))\\nshow(\\\"AP9\\\", await queryAll({\\n  ...base(P, \\\"begins_with(SK, :sk)\\\", \\\"TASK#\\\"),\\n  ScanIndexForward: false,\\n}, 2))\\n\")), mdx(\"p\", null, \"Run \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"node queries.mjs\"), \". Actual local output:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-text\"\n  }, \"AP1 [\\\"Tenant:t1\\\"]\\nAP2 [\\\"Project:p1\\\",\\\"Project:p2\\\"]\\nAP3 [\\\"Member:p1/u1\\\",\\\"Member:p1/u2\\\",\\\"Project:p1\\\",\\\"Task:a1\\\",\\\"Task:a2\\\",\\\"Task:a3\\\"]\\nAP4 [\\\"Member:p1/u1\\\",\\\"Member:p1/u2\\\"]\\nAP5 [\\\"Task:a1\\\",\\\"Task:a2\\\",\\\"Task:a3\\\"]\\nAP6 [\\\"Member:p1/u1\\\",\\\"Member:p2/u1\\\"]\\nAP7 [\\\"User:u1\\\"]\\nAP8 [\\\"Task:a1\\\",\\\"Task:a3\\\"]\\nAP9 [\\\"Task:a3\\\",\\\"Task:a2\\\"]\\n\")), mdx(\"p\", null, \"The project query returns members before metadata because \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"MEMBER#\"), \" sorts before \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"META\"), \". Sort order is a property of your keys, not of entity priority. No access pattern uses a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"Scan\"), \" or a \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"FilterExpression\"), \" to find its records.\"), mdx(\"h3\", null, \"Evolve the model: add AP10 and let DynamoDB backfill\"), mdx(\"p\", null, \"Suppose a responder dashboard arrives later. The tasks already have creation times, task IDs, and an optional \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"escalatedTo\"), \"; add Escalations on those native fields. DynamoDB builds index entries for qualifying existing items. It does not invent missing attributes or derive a responder from application history.\"), mdx(\"p\", null, \"Save this as \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"add-index.mjs\"), \" and run \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"node add-index.mjs\"), \" after setup:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-js\"\n  }, \"import { UpdateTableCommand, DescribeTableCommand } from \\\"@aws-sdk/client-dynamodb\\\"\\nimport { raw, TableName, queryAll, show } from \\\"./client.mjs\\\"\\n\\nawait raw.send(new UpdateTableCommand({\\n  TableName,\\n  AttributeDefinitions: [\\\"tenantId\\\", \\\"escalatedTo\\\", \\\"createdAt\\\", \\\"taskId\\\"]\\n    .map(AttributeName => ({ AttributeName, AttributeType: \\\"S\\\" })),\\n  GlobalSecondaryIndexUpdates: [{ Create: {\\n    IndexName: \\\"Escalations\\\",\\n    KeySchema: [\\n      { AttributeName: \\\"tenantId\\\", KeyType: \\\"HASH\\\" },\\n      { AttributeName: \\\"escalatedTo\\\", KeyType: \\\"HASH\\\" },\\n      { AttributeName: \\\"createdAt\\\", KeyType: \\\"RANGE\\\" },\\n      { AttributeName: \\\"taskId\\\", KeyType: \\\"RANGE\\\" },\\n    ],\\n    Projection: { ProjectionType: \\\"ALL\\\" },\\n  } }],\\n}))\\nlet ready = false\\nfor (let attempt = 0; attempt < 30; attempt++) {\\n  const { Table } = await raw.send(new DescribeTableCommand({ TableName }))\\n  ready = Table.GlobalSecondaryIndexes?.some(\\n    index => index.IndexName === \\\"Escalations\\\" && index.IndexStatus === \\\"ACTIVE\\\"\\n  )\\n  if (ready) break\\n  await new Promise(resolve => setTimeout(resolve, 1000))\\n}\\nif (!ready) throw new Error(\\\"Escalations is still building; inspect DescribeTable\\\")\\nshow(\\\"AP10\\\", await queryAll({\\n  IndexName: \\\"Escalations\\\",\\n  KeyConditionExpression: \\\"tenantId = :t AND escalatedTo = :u\\\",\\n  ExpressionAttributeValues: { \\\":t\\\": \\\"t1\\\", \\\":u\\\": \\\"u2\\\" },\\n}))\\n\")), mdx(\"p\", null, \"Actual local output:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-text\"\n  }, \"AP10 [\\\"Task:a3\\\"]\\n\")), mdx(\"p\", null, \"This demonstrates backfill on previously written data. For a production rollout:\"), mdx(\"ol\", null, mdx(\"li\", {\n    parentName: \"ol\"\n  }, \"Deploy writers that populate the new attributes correctly. If older items lack required values, migrate those items with concurrency checks so newer changes are not overwritten.\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, \"Add the GSI and keep readers on the old route while it builds. Check \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"IndexStatus\"), \" and \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"Backfilling\"), \" with \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"DescribeTable\"), \".\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, \"Monitor \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"OnlineIndexPercentageProgress\"), \" and table write throttling. A poorly distributed index can affect table writes. Follow AWS's \", mdx(\"a\", {\n    parentName: \"li\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.OnlineOps.html\"\n  }, \"online index guidance\"), \".\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, \"Wait for \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"ACTIVE\"), \", check representative results and missing-key cases, then enable the new reader. The sample's short polling deadline is for Local; a real build may take much longer.\"), mdx(\"li\", {\n    parentName: \"ol\"\n  }, \"Retain the old access path until the new one is verified, then retire unused index fields or indexes separately.\")), mdx(\"p\", null, \"Using existing native attributes avoids an application migration whose only purpose is constructing synthetic keys. \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"It does not eliminate DynamoDB's index backfill.\"), \" Changing a base-table primary key is a different migration: existing items must be written under new keys and old keys retired safely.\"), mdx(\"h3\", null, \"Python equivalents for three access patterns\"), mdx(\"p\", null, \"Install boto3 in a virtual environment and save the code as \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"queries.py\"), \". It uses the client interface and its paginator, including the same multi-attribute TaskBoard condition.\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-bash\"\n  }, \"python3 -m venv venv\\nvenv/bin/pip install boto3\\nvenv/bin/python queries.py\\n\")), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-python\"\n  }, \"import json\\nimport boto3\\n\\nclient = boto3.client(\\n    \\\"dynamodb\\\", endpoint_url=\\\"http://localhost:8000\\\", region_name=\\\"us-east-1\\\",\\n    aws_access_key_id=\\\"fake\\\", aws_secret_access_key=\\\"fake\\\",\\n)\\n\\ndef query(label, expression, values, **kwargs):\\n    items = []\\n    for page in client.get_paginator(\\\"query\\\").paginate(\\n        TableName=\\\"SingleTableGuide\\\", KeyConditionExpression=expression,\\n        ExpressionAttributeValues={key: {\\\"S\\\": value} for key, value in values.items()},\\n        **kwargs,\\n    ):\\n        items.extend(page.get(\\\"Items\\\", []))\\n    print(label, json.dumps([\\n        item[\\\"entity\\\"][\\\"S\\\"] + \\\":\\\" + item[\\\"id\\\"][\\\"S\\\"] for item in items\\n    ], separators=(\\\",\\\", \\\":\\\")))\\n\\nquery(\\\"AP4\\\", \\\"PK = :p AND begins_with(SK, :s)\\\",\\n      {\\\":p\\\": \\\"TENANT#t1#PROJECT#p1\\\", \\\":s\\\": \\\"MEMBER#\\\"}, ConsistentRead=True)\\nquery(\\\"AP6\\\", \\\"GSI1PK = :p AND begins_with(GSI1SK, :s)\\\",\\n      {\\\":p\\\": \\\"TENANT#t1#USER#u1\\\", \\\":s\\\": \\\"PROJECT#\\\"}, IndexName=\\\"Relationships\\\")\\nquery(\\\"AP8\\\", \\\"tenantId = :t AND projectId = :p AND #s = :s AND createdAt BETWEEN :a AND :b\\\",\\n      {\\\":t\\\": \\\"t1\\\", \\\":p\\\": \\\"p1\\\", \\\":s\\\": \\\"OPEN\\\", \\\":a\\\": \\\"2026-10-01T00:00:00Z\\\",\\n       \\\":b\\\": \\\"2026-10-31T23:59:59Z\\\"},\\n      IndexName=\\\"TaskBoard\\\", ExpressionAttributeNames={\\\"#s\\\": \\\"status\\\"})\\n\")), mdx(\"p\", null, \"Actual local output:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-text\"\n  }, \"AP4 [\\\"Member:p1/u1\\\",\\\"Member:p1/u2\\\"]\\nAP6 [\\\"Member:p1/u1\\\",\\\"Member:p2/u1\\\"]\\nAP8 [\\\"Task:a1\\\",\\\"Task:a3\\\"]\\n\")), mdx(\"h2\", null, \"DynamoDB design patterns used by this model\"), mdx(\"h3\", null, \"One-to-many: item collections or deliberate duplication\"), mdx(\"p\", null, \"Project-to-task is one-to-many. Each task is a separate item under the project key, so tasks can be updated independently. Putting every task into a list on the project item would create a growing item and a shared write target. DynamoDB's \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html\"\n  }, \"item limit is 400 KB\"), \", including attribute names and values.\"), mdx(\"p\", null, \"Denormalize small, frequently read display fields when avoiding another request matters. Record whether a duplicated project name is a historical snapshot or a copy that must follow renames. \\\"Eventually updated\\\" without an update mechanism is a data defect, not a design.\"), mdx(\"h3\", null, \"Many-to-many: adjacency list plus an inverted GSI\"), mdx(\"p\", null, \"Each membership is an edge from a project to a user. AP4 traverses project to users in the base table. The same edge sets \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI1PK\"), \" to the user key and \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI1SK\"), \" to the project, so AP6 traverses user to projects. This is the \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html\"\n  }, \"adjacency-list pattern\"), \", with a selective inverted index rather than blindly swapping every item's PK and SK.\"), mdx(\"p\", null, mdx(\"img\", {\n    parentName: \"p\",\n    \"src\": \"/images/posts/dynamodb-single-table-design-guide/std-many-to-many.svg\",\n    \"alt\": \"Membership edges are read from project to user in the base table and from user to project in the Relationships GSI; deleting one edge removes its index entry asynchronously.\"\n  })), mdx(\"p\", null, \"Relationships is also \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"overloaded\"), \": project metadata uses that same index for the tenant's project directory. Different \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"GSI1PK\"), \" prefixes keep the two request families separate. Store a new membership once, and DynamoDB maintains its index entry. Revoking membership deletes the edge; the GSI can briefly lag, so check the authoritative base item when making a fresh authorization decision.\"), mdx(\"h3\", null, \"Hierarchical and time-ordered sort keys\"), mdx(\"p\", null, mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"TASK#<createdAt>#<taskId>\"), \" lets AP5 select tasks using \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"begins_with(SK, \\\"TASK#\\\")\"), \", while AP9 reverses the order for recent tasks. Keep timestamps in UTC with consistent precision; mixed offsets or fractional formats can break chronological string ordering. The final task ID breaks timestamp ties.\"), mdx(\"p\", null, \"A deeper hierarchy could use \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"TASK#<taskId>#COMMENT#<createdAt>#<commentId>\"), \" to group one task's comments. That order favors querying comments for a known task, not all comments by time. Put the field used to narrow the query first. AWS calls this the \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/data-modeling-blocks.html\"\n  }, \"composite-sort-key building block\"), \".\"), mdx(\"h3\", null, \"Sparse indexes\"), mdx(\"p\", null, \"Only a3 carries every key attribute required by Escalations. Other tasks retain their business data but have no escalation index entry. Remove \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"escalatedTo\"), \" when the escalation ends; an empty string or \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"null\"), \" is not a substitute for an absent key attribute. Status changes alone do not remove an escalation unless your write also removes that attribute.\"), mdx(\"p\", null, \"TaskBoard is sparse too: projects and memberships have \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"tenantId\"), \" and \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"projectId\"), \", but lack its remaining keys. A missing key component excludes the item, which makes write validation essential. The \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html\"\n  }, \"GSI documentation\"), \" describes index membership and projections.\"), mdx(\"h3\", null, \"Uniqueness requires a transaction, not a GSI lookup\"), mdx(\"p\", null, \"A GSI does not enforce uniqueness. To reserve a tenant's project slug, write a claim item at \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"PK = TENANT#t1#SLUG#roadmap, SK = META\"), \" together with the project. Both puts require that their target item does not exist. Competing requests cannot both acquire the same claim.\"), mdx(\"p\", null, \"Save this optional extension as \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"unique.mjs\"), \" and run \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"node unique.mjs\"), \" after the ten read examples. It adds project p3 and its claim, then proves that a conflicting p4 is not partially created:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-js\"\n  }, \"import { TransactWriteCommand } from \\\"@aws-sdk/lib-dynamodb\\\"\\nimport { db, TableName, queryAll } from \\\"./client.mjs\\\"\\n\\nasync function createProject(id, slug) {\\n  const project = {\\n    PK: `TENANT#t1#PROJECT#${id}`, SK: \\\"META\\\", entity: \\\"Project\\\", id,\\n    tenantId: \\\"t1\\\", projectId: id, name: \\\"Roadmap\\\", slug,\\n    GSI1PK: \\\"TENANT#t1\\\", GSI1SK: `PROJECT#${id}`,\\n  }\\n  const claim = {\\n    PK: `TENANT#t1#SLUG#${slug}`, SK: \\\"META\\\", entity: \\\"Slug\\\", id: slug,\\n    ownerId: id, tenantId: \\\"t1\\\",\\n  }\\n  await db.send(new TransactWriteCommand({\\n    TransactItems: [project, claim].map(Item => ({ Put: {\\n      TableName, Item, ConditionExpression: \\\"attribute_not_exists(PK)\\\",\\n    } })),\\n  }))\\n}\\nawait createProject(\\\"p3\\\", \\\"roadmap\\\")\\nconsole.log(\\\"Slug claimed for p3\\\")\\ntry {\\n  await createProject(\\\"p4\\\", \\\"roadmap\\\")\\n  throw new Error(\\\"Duplicate slug was accepted\\\")\\n} catch (error) {\\n  if (error.name !== \\\"TransactionCanceledException\\\" ||\\n      !error.CancellationReasons?.some(r => r.Code === \\\"ConditionalCheckFailed\\\")) throw error\\n  const items = await queryAll({\\n    ConsistentRead: true,\\n    KeyConditionExpression: \\\"PK = :pk AND SK = :sk\\\",\\n    ExpressionAttributeValues: { \\\":pk\\\": \\\"TENANT#t1#PROJECT#p4\\\", \\\":sk\\\": \\\"META\\\" },\\n  })\\n  if (items.length) throw new Error(\\\"Transaction left a partial project\\\")\\n  console.log(\\\"Duplicate rejected; p4 absent\\\")\\n}\\n\")), mdx(\"p\", null, \"Actual local output:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-text\"\n  }, \"Slug claimed for p3\\nDuplicate rejected; p4 absent\\n\")), mdx(\"p\", null, \"Normalize slugs before constructing claim keys, and restrict or encode delimiter characters in identifiers. Renaming a slug requires a transaction that acquires the new claim, updates the project, and deletes the old claim with an ownership condition. Preserve a request's idempotency identity across retries so a lost success response is distinguishable from a genuine collision. See \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-transactions/\"\n  }, \"DynamoDB transactions\"), \" and AWS's \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://aws.amazon.com/blogs/database/simulating-amazon-dynamodb-unique-constraints-using-transactions/\"\n  }, \"unique-constraint pattern\"), \".\"), mdx(\"h3\", null, \"Counters and aggregates with Streams\"), mdx(\"p\", null, \"A dashboard count of open tasks can be a separate summary item. With \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"NEW_AND_OLD_IMAGES\"), \", a \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-streams/\"\n  }, \"DynamoDB Streams\"), \" consumer computes the change in the open-task contribution: insert-open adds one, open-to-done subtracts one, and deleting an open task subtracts one.\"), mdx(\"p\", null, \"An atomic \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"ADD\"), \" prevents lost concurrent increments, but replaying it increments twice. Lambda \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/lambda/latest/dg/with-ddb.html\"\n  }, \"delivers stream events at least once\"), \". Deduplicate each event and update the counter in the same transaction, or maintain versioned per-task contributions. Exclude summary and deduplication items from the consumer's input logic to avoid feedback loops. Include an initial rebuild and reconciliation path; a consumer starting today does not count old tasks automatically.\"), mdx(\"p\", null, \"This produces an eventually consistent aggregate. If a limit must be enforced immediately, update the authoritative counter and task in the same application transaction with a condition. A hot shared counter may need sharding, which changes how reads combine totals. The sample above tests the data model and transactions; it does not deploy a Streams consumer.\"), mdx(\"h2\", null, \"Pitfalls: capacity, cost, consistency, and debugging\"), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"Hot keys.\"), \" A shared table does not require a single partition-key value. Our base keys distribute work by project, but one busy project or responder can still concentrate load. Low-cardinality index keys such as \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"OPEN\"), \" alone are especially risky. Diagnose the specific table or index, then apply the strategies in \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-hot-partitions/\"\n  }, \"DynamoDB hot partitions\"), \".\"), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"Item size and unbounded collections.\"), \" Split large documents into independently addressable items or store payloads in S3. Paginate growing histories. Strongly consistent queries see committed items, but a multi-page query is not an atomic snapshot of a changing project. Use the \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html\"\n  }, \"transactional read API\"), \" when you need an atomic view of a known set of items.\"), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"GSI write amplification.\"), \" Index storage and writes are additional work. A small nontransactional task insert that creates one entry in TaskBoard and one in Escalations consumes one base-table write plus two index-entry writes, assuming each is at most 1 KB. Changing an indexed key can require deleting the old index entry and writing a new one. An update that changes no indexed keys or projected attributes need not update that index. Narrow projections and sparse membership reduce this overhead; \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"ALL\"), \" is chosen here for clarity, not as a universal default.\"), mdx(\"p\", null, \"For one million inserts with that three-write shape, us-east-1 Standard on-demand consumes three million WRUs. At $0.625 per million WRUs, that is $1.875 in write charges, compared with $0.625 for base-table writes alone, before storage and other usage. This is calculated from the stated item-size assumptions and \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://aws.amazon.com/dynamodb/pricing/\"\n  }, \"AWS pricing\"), \", not measured billing from Local.\"), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"Accidental read amplification.\"), \" Index queries return projected attributes, not arbitrary base-table fields. Inspect every response contract before using \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"KEYS_ONLY\"), \". A filter discards already-read data; it does not repair a missing key-based access pattern or reduce the capacity consumed by those reads.\"), mdx(\"p\", null, mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"Unreadable data and key drift.\"), \" Keep \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"entity\"), \", domain IDs, and the key-construction functions together. Validate required index attributes on every write, and make deletion rules explicit for project children and membership edges. A \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-orm/\"\n  }, \"DynamoDB modeling library\"), \" can centralize this contract; it cannot invent missing access patterns. The wider \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-best-practices/\"\n  }, \"best-practices checklist\"), \" covers operational checks beyond the model.\"), mdx(\"p\", null, \"In \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-single-table-design-tool/\"\n  }, \"Dynobase's Single-Table Designer\"), \", lay out the entity rows and access patterns, then inspect the key and GSI columns as you test each query. The \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-data-modeling/\"\n  }, \"data-modeling feature page\"), \" describes that workflow. The existing screenshot below shows a banking example, not the SaaS fixture or a verification run.\"), mdx(\"p\", null, mdx(\"img\", {\n    parentName: \"p\",\n    \"src\": \"/images/features-3/designer.jpg\",\n    \"alt\": \"Dynobase Single-Table Designer showing a banking model's access patterns, primary-key columns, entity types, and GSI columns.\"\n  })), mdx(\"p\", null, \"After loading sample data, Dynobase's \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-schema-explorer/\"\n  }, \"schema explorer\"), \" helps inspect the actual item shapes. Compare them with the written model so an omitted index attribute is visible during debugging. The \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"/dynamodb-single-table-design-examples/\"\n  }, \"single-table example models\"), \" provide other domains to practice with.\"), mdx(\"p\", null, \"DynamoDB Local verifies request syntax and returned data, but it does not reproduce production throughput limits, index propagation delays, or transaction contention. Those limits are documented in AWS's \", mdx(\"a\", {\n    parentName: \"p\",\n    \"href\": \"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.UsageNotes.html\"\n  }, \"Local usage notes\"), \". Once finished with this in-memory exercise, stop its container:\"), mdx(\"pre\", null, mdx(\"code\", {\n    parentName: \"pre\",\n    \"className\": \"language-bash\"\n  }, \"docker stop dynamodb-local\\n\")), mdx(FAQ, {\n    questions: [{\n      question: \"What is single-table design in DynamoDB?\",\n      answer: \"It stores several entity types in one table and chooses keys so related records can be retrieved together. The design starts from specific application access patterns rather than one table per entity.\"\n    }, {\n      question: \"When should I avoid DynamoDB single-table design?\",\n      answer: \"Avoid a complex shared model when entities are mostly queried independently, service boundaries need separate controls, or the team cannot maintain its key contracts. For changing ad hoc joins and analytics, consider a relational or analytical store; splitting DynamoDB into more tables does not add those query capabilities.\"\n    }, {\n      question: \"How do you model many-to-many relationships in DynamoDB?\",\n      answer: \"Store each relationship as an edge item under one entity's partition key, then use an inverted GSI for the reverse lookup. The worked membership model queries users in a project and projects for a user from the same stored edge.\"\n    }, {\n      question: \"Do multi-attribute GSI keys replace concatenated keys?\",\n      answer: \"They can replace synthetic GSI fields when the index maps naturally to existing attributes. GSIs support up to four partition-key and four sort-key attributes, but base-table keys are unchanged and overloaded indexes can still benefit from synthetic keys.\"\n    }, {\n      question: \"Can I add a new access pattern after creating a single table?\",\n      answer: \"Yes, often by adding a GSI over existing attributes. DynamoDB backfills qualifying items and the index becomes queryable when ACTIVE; missing attributes still need an application data migration.\"\n    }, {\n      question: \"Does a single-table query return everything in one request?\",\n      answer: \"Only when the required data fits one query page. Query evaluates up to 1 MB before filtering, so larger collections need pagination, and a query is not an atomic snapshot across multiple items.\"\n    }],\n    mdxType: \"FAQ\"\n  }));\n}\n;\nMDXContent.isMDXComponent = true;","frontmatter":{"publishedDate":"2026-10-23","updatedDate":"2026-10-23","path":"/dynamodb-single-table-design-guide/","metaTitle":"DynamoDB Single-Table Design: Patterns and Worked Model","metaDescription":"Design a DynamoDB single-table model with 10 tested queries, many-to-many relationships, native multi-attribute GSI keys, and clear trade-offs.","title":"DynamoDB single-table design: a practical guide for 2026","authorName":"Rafal Wilinski","authorLink":"https://x.com/rafalwilinski","authorImage":"/images/authors/rafal-wilinski.jpg","image":"/images/posts/dynamodb-single-table-design-guide/social.png"}}},"pageContext":{}},
    "staticQueryHashes": ["3649515864"]}