Skip to content

DynamoDB Data Types: The Complete Reference

DynamoDB has ten data types across three families. Three of them can be used in keys. Several have restrictions that aren’t obvious until you hit them in production, and one of them will silently corrupt your data in JavaScript if you’re not careful.

This is the reference I wanted when I started.

The ten types

FamilyTypeDescriptorExample
ScalarStringS"Acme Corp"
ScalarNumberN42, 3.14, -7
ScalarBinaryBbase64-encoded bytes
ScalarBooleanBOOLtrue
ScalarNullNULLnull
DocumentMapM{ city: "Mysuru" }
DocumentListL[1, "two", true]
SetString SetSS["red", "green"]
SetNumber SetNS[1, 2, 3]
SetBinary SetBSbase64 values

The descriptor is what appears in the raw wire format. If you’re using the DocumentClient or a library like ElectroDB, you’ll rarely see them, but they surface in DynamoDB Streams records, ExpressionAttributeValues, and the console’s JSON view.

Only three types can be keys

Partition keys and sort keys must be S, N, or B. No booleans, no maps, no sets.

This constraint shapes more of your schema than it first appears. It’s why composite keys are built by concatenating values into a single string rather than by using a map. It’s why status flags live in the key as STATUS#active rather than as a boolean.

Key types are also fixed at table creation and cannot be altered.

Sort keys are almost always S in single-table design. A numeric sort key sorts numerically, which is convenient, but it forfeits the ability to prefix values with an entity type. No prefixes means no sort key overloading, which means no single-table design. The trade is rarely worth it.

Strings

UTF-8 encoded, up to 400 KB (the whole item limit). Compared and ordered by UTF-8 byte order, which is the source of the most common sort key bug:

"ORDER#1"
"ORDER#10"    ← sorts before ORDER#2
"ORDER#2"

Zero-pad numeric string components to a fixed width, or use a lexicographically sortable identifier like a ULID. See ULIDs vs UUIDs vs Timestamps.

Empty strings are allowed for non-key attributes since May 2020. This changed, and a lot of guidance online predates it. Key attributes still cannot be empty.

Numbers, and the precision trap

DynamoDB numbers support 38 significant digits, with an exponent range from roughly 10⁻¹²⁸ to 10¹²⁶. Internally they’re transmitted as strings, which is why that precision is possible at all.

This exceeds what JavaScript can represent. A JS number is a double, exact only up to 2⁵³ - 1 (about 9.007 × 10¹⁵). Store a 20-digit number, read it back through the standard DocumentClient, and you silently lose precision.

Where this bites: Twitter-style snowflake IDs, financial values in minor units at large scale, high-resolution nanosecond timestamps.

Two fixes:

Store as a string. If you never do arithmetic on it in the database, S is the honest type. IDs almost always belong here.

Use the SDK’s BigInt handling. AWS SDK v3’s DocumentClient supports wrapNumbers: true, returning a NumberValue object rather than a lossy number, letting you convert to BigInt or a decimal library yourself.

Leading and trailing zeros are trimmed on write. 01.50 comes back as 1.5. If the exact string representation matters, use S.

Binary

Arbitrary bytes, base64-encoded in transit. DynamoDB decodes before storing, so the 400 KB item limit applies to the decoded size.

Valid as a key type, compared as unsigned bytes. Uncommon in practice. Most teams that reach for binary end up storing the payload in S3 and keeping a pointer in DynamoDB, which is usually right - anything approaching 400 KB doesn’t belong in an item.

Genuine uses: compressed payloads, cryptographic material, small thumbnails.

Boolean and Null

BOOL is true or false. Cannot be a key.

NULL represents an explicitly null value, which is distinct from the attribute being absent. This distinction matters more than it seems:

StateBehaviour
attribute_not_exists(x)True when absent, false when NULL
Item in a sparse indexAbsent → excluded. NULL → key type invalid, write rejected

To remove an item from a sparse index you must REMOVE the attribute, not set it to NULL. Setting it to NULL fails, because NULL isn’t a valid key type.

As a general rule, prefer absence to NULL. It’s cheaper, it works with attribute_not_exists, and it composes with sparse indexes.

Maps and Lists

Maps are unordered key-value structures. Keys are strings, values can be any type including nested maps and lists.

Lists are ordered and heterogeneous. [1, "two", {"three": 3}] is valid.

Both nest to 32 levels deep. Both can be updated at a path without rewriting the item:

SET address.city = :city
SET tags[0] = :tag
SET #order.items[2].quantity = :qty

Path-level updates are cheap for reads but not for writes. A write consumes capacity based on the size of the entire item, not the size of the change. Updating one field in a 300 KB item costs 300 KB of write capacity. Large documents that update frequently are an anti-pattern; split them into separate items.

The other reason to prefer separate items: you cannot query into a map or list. Nested values can only be filtered after the item is read, which means paying to read everything. If you need to query by it, it belongs in a key or an index, not inside a document.

Sets

SS, NS, and BS are unordered collections with three rules:

  1. Homogeneous. All elements the same type.
  2. No duplicates. Enforced by DynamoDB.
  3. Never empty. Removing the last element deletes the attribute entirely.

Sets support atomic add and delete without reading the item first:

ADD tags :newTags
DELETE tags :oldTags

This is genuinely useful for concurrent updates. Two Lambdas adding tags to the same item won’t clobber each other, which they would with a List.

Sets versus Lists: use a Set when elements are unique, unordered, and updated atomically. Use a List when order matters or elements are objects (sets can’t hold maps).

The empty-set rule catches people. Code that removes elements one at a time will find the attribute gone rather than empty. Handle absence, not emptiness.

There is no date type

DynamoDB has no native date or timestamp type. Two conventions:

ISO 8601 strings (2026-08-17T14:22:00Z). Sorts lexicographically in chronological order, human readable in the console, works directly in sort keys and range queries. This is the default choice, and it’s what I use.

Epoch numbers. More compact, easier for arithmetic. Required for TTL - DynamoDB’s TTL attribute must be a Number containing a Unix timestamp in seconds. Milliseconds is the classic mistake: your items get a deletion date ~50,000 years out and never expire.

Non-ISO date formats do not sort correctly. 08/17/2026 and Aug 17 2026 are both broken as sort keys.

Item size limits

400 KB per item, including attribute names. Names count, so verbose names on high-volume items cost real money at scale.

Related limits worth knowing:

LimitValue
Item size400 KB
Partition key value2048 bytes
Sort key value1024 bytes
Nesting depth32 levels
BatchWriteItem25 items or 16 MB
TransactWriteItems100 items or 4 MB
Query/Scan result page1 MB

Write capacity is charged per 1 KB rounded up, read capacity per 4 KB. A 4.1 KB item costs 5 WCU to write and 2 RCU to read with strong consistency.

Type descriptors and the DocumentClient

The low-level API is explicit about types:

{
  "tenantId": { "S": "t_01" },
  "seats":    { "N": "10" },
  "active":   { "BOOL": true },
  "tags":     { "SS": ["pro", "annual"] }
}

The DocumentClient marshals this automatically:

import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"

const doc = DynamoDBDocumentClient.from(client)
// { tenantId: "t_01", seats: 10, active: true, tags: Set(["pro", "annual"]) }

Two marshalling options worth setting deliberately:

  • removeUndefinedValues: true - strips undefined rather than throwing. Almost always what you want.
  • wrapNumbers: true - preserves precision beyond JS safe integers, at the cost of handling NumberValue objects.

You still see raw descriptors in DynamoDB Streams records, which is worth knowing before you write your first stream handler and wonder why every value is wrapped in a single-key object.


Type choices in keys are permanent, and their consequences are hard to see until they’re deployed. I’m building singletable.dev to make them visible while the schema is still a draft.

Tejovanth N

Tejovanth builds on DynamoDB in production: rasika.life, rekha.app, rrmstays. All single-table with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.