DynamoDB Interview Questions and Answers

Last updated:

Check out 40 of the most common DynamoDB interview questions, then take an AI-powered practice interview

NoSQLAWSServerlessGlobal TablesStreams
40+
Questions
14
Basic
18
Intermediate
8
Advanced
Q1

What is DynamoDB and what problems does it solve?

BasicFundamentals

Answer

DynamoDB is AWS's fully-managed, serverless NoSQL database. It was built (and famously described in a 2007 SOSP paper) to solve scaling pain points Amazon hit with relational databases during peak shopping events: unpredictable latency under load, expensive vertical scaling, and operational overhead of running clustered RDBMS. DynamoDB takes a different shape, it's a key-value + document store with O(1) lookups on a hash-partitioned key, automatic horizontal partitioning, and no minimum cluster size to pay for.

You give it a primary key, you get back single-digit-millisecond reads/writes at any scale from 1 request/sec to millions. There are no servers to provision, no patching, no replica failover to manage. The trade-off is that you lose SQL's flexibility, you must design your access patterns up front, because joins, complex aggregations, and ad-hoc queries are either impossible or expensive (Scans).

Under the hood a table is split into partitions of roughly 10 GB, and DynamoDB splits a partition automatically when it outgrows that size or when its traffic exceeds the per-partition ceiling of about 3,000 RCU and 1,000 WCU. Those ceilings, plus the 400 KB item limit, the 1 MB page limit on Query and Scan, and the 100-item limit on a transaction, are the numbers that actually shape a design. The published SLA is 99.99% availability for a single-region table and 99.999% once you add Global Tables replicas. A senior interviewer's follow-up is almost always 'what breaks first when you get this wrong', and the honest answer is throttling: one hot partition key starts returning ProvisionedThroughputExceededException while the table as a whole sits mostly idle.

Key Points

  • Fully-managed, serverless NoSQL, no servers to run
  • Single-digit-millisecond latency at any scale
  • Key-value + document model on a hash-partitioned primary key
  • You design access patterns up front; ad-hoc queries are expensive
Q2

What's the difference between a partition key and a sort key?

BasicData Modelling

Answer

The partition key (PK, also called hash key) determines which physical partition the item lives on, DynamoDB hashes it and routes to a node. A table with only a PK is a pure key-value store: you can only fetch one item by its exact key. The sort key (SK, also called range key) is the second part of a composite primary key.

Items with the same PK are stored together on the same partition, sorted by SK. This lets you do range queries: 'all orders for user 42 between Jan and Mar', 'the last 10 messages in chat room X'. The combination (PK, SK) must be unique.

Choosing the right PK is the single most important DynamoDB modelling decision, get it wrong and you create hot partitions; get it right and your queries scale linearly. Two mechanical details interviewers probe. Key attributes must be scalar String, Number or Binary, never a Map, List or Set, and the key schema is frozen at CreateTable, so changing a partition key later means creating a new table and backfilling into it.

On reads, KeyConditionExpression allows only equality on the partition key, while the sort key supports =, <, <=, >, >=, BETWEEN and begins_with. Put a non-key attribute in KeyConditionExpression and you get 'ValidationException: Query condition missed key schema element', which is the most common first-week error. All items sharing a partition key form an item collection; if the table has a Local Secondary Index, that collection is capped at 10 GB and further writes to it fail with ItemCollectionSizeLimitExceededException.

// Table: Orders
// PK = USER#<userId>, SK = ORDER#<isoTimestamp>

// Get all of user 42's orders, newest first:
await ddb.query({
  TableName: "Orders",
  KeyConditionExpression: "PK = :pk",
  ExpressionAttributeValues: { ":pk": "USER#42" },
  ScanIndexForward: false, // descending
  Limit: 20,
}).promise();

Key Points

  • PK chooses the partition, controls scale and hot-spot risk
  • SK orders items within a PK, enables range queries
  • (PK, SK) together must be unique
Q3

What is the document model in DynamoDB?

BasicData Modelling

Answer

DynamoDB items are JSON-like documents, you can store nested maps, lists, sets, strings, numbers, binary, booleans, and null. The maximum item size is 400 KB (including the key names). Unlike strict relational tables, two items with the same primary key can have entirely different attributes, DynamoDB is schemaless beyond the key.

In practice you do impose a schema in your code (often via a type system or a library like ElectroDB), but the database itself doesn't enforce it. The document model is what lets single-table design work: User items, Order items, and Product items can all sit in the same table with completely different attribute shapes, distinguished only by their PK/SK pattern. Details that bite in production: the 400 KB budget counts attribute names as well as values, which is why single-table designs use short generic names like PK, SK and GSI1PK; nesting is capped at 32 levels; and empty strings are allowed in ordinary attributes (since 2020) but still rejected in key and index-key attributes.

With AWS SDK v3 you rarely hand-write the wire format ({ 'S': 'abc' }), you wrap the low-level client in DynamoDBDocumentClient from @aws-sdk/lib-dynamodb and pass plain JavaScript objects. When an item genuinely will not fit, the standard move is to put the payload in S3 and keep only the object key plus metadata in the item. The usual follow-up is 'what stops a single table becoming a junk drawer', and the answer is a type attribute on every item plus an entity layer in code, ElectroDB in Node or the enhanced client in Java.

Q4

What are the data types DynamoDB supports?

BasicData Modelling

Answer

DynamoDB has three categories: scalars (String, Number, Binary, Boolean, Null), documents (Map, List), and sets (String Set, Number Set, Binary Set). Numbers are stored as variable-length decimals with up to 38 digits of precision, no separate int/float distinction. Strings are UTF-8 and can be empty (since 2020).

Sets are unordered collections of unique values of a single type, handy for tags or member lists, and they support atomic ADD/DELETE operations. Lists preserve order and can mix types. Maps are nested objects, also mixed-type.

There's no native date type, store ISO 8601 strings (good for sorting) or Unix epoch numbers (good for TTL). The wire format uses one-letter type descriptors: S, N, B, BOOL, NULL, M, L, SS, NS, BS. Numbers travel as strings, which matters in JavaScript, where anything past 2^53 loses precision unless you read it with wrapNumbers: true or store it as a String.

Sets cannot be empty: removing the last member with the DELETE action deletes the whole attribute rather than leaving an empty set, so code that assumes the attribute exists will throw on the next read. Binary is base64 on the wire but billed at its decoded size. The classic production failure in this area is TTL, where the attribute must be a Number holding epoch seconds: write an ISO string or milliseconds instead and DynamoDB silently ignores it, nothing ever expires, and the table grows quietly for months until someone reads the bill. A related trap is using a Number for an ID with leading zeros, since 007 comes back as 7.

💡 Pro Tip: Use ISO 8601 strings for timestamps in sort keys, they sort lexicographically the same way they sort chronologically.
Q5

How do you create a basic DynamoDB table?

BasicOperations

Answer

You define the table name, the primary key schema (PK only, or PK + SK), and the billing mode (PAY_PER_REQUEST for on-demand, PROVISIONED for fixed capacity). You only declare the key attributes, every other attribute is added implicitly when you write an item with it. This is core to DynamoDB's flexibility: you never run an ALTER TABLE for a new field.

AttributeDefinitions must list exactly the key attributes used by the table and its indexes, no more and no less, or you get 'ValidationException: One or more parameter values were invalid: Number of attributes in KeySchema does not exactly match number of attributes defined in AttributeDefinitions'. CreateTable is asynchronous: the table sits in CREATING before it flips to ACTIVE, so scripts should use the waitUntilTableExists waiter rather than a sleep. Options worth setting on day one are DeletionProtectionEnabled: true on anything production, StreamSpecification if you might ever want change data capture (enabling it later does not backfill history), SSESpecification for a customer-managed KMS key, and TableClass: STANDARD_INFREQUENT_ACCESS for archival tables where storage dominates request cost. In a real team the table lives in CloudFormation, CDK or Terraform rather than an SDK call, because the key schema is the one thing you cannot change afterwards, and because Terraform will happily plan a destroy-and-recreate if you edit KeySchema by hand.

// AWS SDK v3 (Node.js)
import { CreateTableCommand } from "@aws-sdk/client-dynamodb";

await client.send(new CreateTableCommand({
  TableName: "Orders",
  AttributeDefinitions: [
    { AttributeName: "PK", AttributeType: "S" },
    { AttributeName: "SK", AttributeType: "S" },
  ],
  KeySchema: [
    { AttributeName: "PK", KeyType: "HASH" },
    { AttributeName: "SK", KeyType: "RANGE" },
  ],
  BillingMode: "PAY_PER_REQUEST",
}));
Q6

What's the difference between Query and Scan?

BasicReads

Answer

Query reads items with a specific partition key, it's O(1) to find the partition and then scans only the items inside it (optionally filtered by sort key range). It's the workhorse: efficient, predictable, scales to billions of items. Scan reads the ENTIRE table, item by item, across every partition.

It's expensive in both money (RCU charges per item read, even if filtered out) and time (consumes table-wide capacity, can throttle other workloads). The rule for production: never Scan a large table on the hot path. Acceptable Scan uses: small lookup tables (config, feature flags), one-time data migrations, exporting to S3 via parallel scan.

If you find yourself wanting to Scan with a Filter, that's a sign your access pattern needs a GSI. Two response fields make this concrete: Count is what you got back, ScannedCount is what DynamoDB actually read and charged for, so a call returning Count 3 with ScannedCount 90,000 tells you a FilterExpression is doing work the key schema should be doing. The 1 MB page cap is applied before the filter runs, so a filtered Scan can legitimately return zero items and still hand you a LastEvaluatedKey.

Select: COUNT saves nothing either, it still reads every item. If you genuinely need the whole table for analytics or a one-off migration, use Export to S3 instead: it reads the continuous backup, consumes no RCU, and does not compete with production traffic. Parallel Scan (the Segment and TotalSegments parameters) makes a full read faster but multiplies the capacity burn, so cap the segment count and run it against a table in on-demand mode if you can.

// The diagnostic: compare Count with ScannedCount.
const res = await ddb.scan({
  TableName: "Orders",
  FilterExpression: "#s = :pending",
  ExpressionAttributeNames: { "#s": "status" },
  ExpressionAttributeValues: { ":pending": "PENDING" },
  ReturnConsumedCapacity: "TOTAL",
}).promise();

console.log(res.Count);          // 3 items returned
console.log(res.ScannedCount);   // 90000 items read and billed
console.log(res.ConsumedCapacity.CapacityUnits);

// Same access pattern on a GSI: Count === ScannedCount.
await ddb.query({
  TableName: "Orders",
  IndexName: "GSI1",
  KeyConditionExpression: "GSI1PK = :pending",
  ExpressionAttributeValues: { ":pending": "STATUS#PENDING" },
}).promise();

Key Points

  • Query = scoped to one PK, efficient
  • Scan = reads the whole table, expensive
  • Filters apply AFTER reading, they don't save RCUs
  • If you need to Scan, you probably need a GSI
Q7

What's the difference between eventually-consistent and strongly-consistent reads?

BasicConsistency

Answer

DynamoDB replicates every write to three storage nodes across Availability Zones. A write returns success after two of the three acknowledge. By default, reads are eventually-consistent, they may hit a replica that hasn't received the latest write yet, so you might see slightly stale data (typically within a second).

Strongly-consistent reads always go to the leader replica and return the latest acknowledged write, they cost twice as many RCUs and have slightly higher latency. Use eventually-consistent reads for most workloads (feeds, browsing, analytics). Use strongly-consistent reads only when you immediately read-after-write something and absolutely need the latest value, for example, after creating a resource and immediately rendering it in a UI.

Global Secondary Indexes (GSIs) are ALWAYS eventually-consistent, you cannot force strong consistency on a GSI read. Two follow-ups come up often. First, transactions: TransactGetItems is strongly consistent by definition, and a strongly-consistent GetItem costs 1 RCU per 4 KB against 0.5 for an eventual one, so a service that sets ConsistentRead: true everywhere quietly doubles its read bill for no product benefit.

Second, regions: a strongly-consistent read is strong only inside its own region, so on a classic Global Table you can write in ap-south-1 and read stale data in us-east-1 no matter what ConsistentRead says. The multi-Region strong consistency option added to global tables in 2025 changes that, at the cost of cross-region write latency. In practice most read-after-write bugs are not fixed by ConsistentRead at all, they are GSI lag: somebody writes an item, immediately queries GSI1 for it, gets an empty list, and blames the database. The fix there is to read the base table by key on that hop.

await ddb.get({
  TableName: "Orders",
  Key: { PK: "USER#42", SK: "ORDER#2026-05-12" },
  ConsistentRead: true, // costs 2x RCU, latest data
}).promise();
Q8

What are RCUs and WCUs?

BasicCapacity

Answer

Read Capacity Units (RCU) and Write Capacity Units (WCU) are DynamoDB's unit of throughput in provisioned mode. 1 WCU = 1 write per second for items up to 1 KB. Larger items round up: a 1.5 KB write costs 2 WCUs. Transactional writes cost 2 WCUs. 1 RCU = 1 strongly-consistent read per second for items up to 4 KB, OR 2 eventually-consistent reads per second of the same size, OR 0.5 transactional reads per second.

Quick math you'll be expected to do in interviews: 'How many WCUs to write 1,000 items per second, each 3 KB?' → 3 WCUs per item × 1,000 = 3,000 WCUs. 'How many RCUs for 500 eventually-consistent reads/sec on 8 KB items?' → 8 KB = 2 RCUs strong, 1 RCU eventual; 500 × 1 = 500 RCUs. Two details people miss.

Capacity for a Query is charged on the total bytes read from the partition before FilterExpression runs, rounded up in 4 KB units across the whole page rather than per item, so fetching fifty 200-byte items in one Query costs a fraction of fifty GetItems. Writes round per item, so 1,000 writes of 200 bytes each cost 1,000 WCUs, not 200. Every GSI write is billed separately at the size of the projected index item, which is why a table with four ALL-projection indexes can carry roughly five times the write bill you budgeted for.

On-demand mode uses the same arithmetic, you just pay in read request units and write request units, where one WRU is the work of one WCU. Interviewers often close with 'now price that in on-demand', so remember request units are billed per million.

Key Points

  • 1 WCU = 1 write/sec of 1 KB item
  • 1 RCU = 1 strong or 2 eventual reads/sec of 4 KB item
  • Round item size UP to nearest 1 KB (writes) or 4 KB (reads)
  • Transactional ops cost 2x
Q9

What's the difference between on-demand and provisioned billing modes?

BasicCapacity

Answer

On-demand (PAY_PER_REQUEST) bills per request, no capacity to set, no auto-scaling to tune, scales instantly to any traffic. Costs ~7× more per unit than provisioned at steady-state but you pay $0 when idle. Provisioned bills for the RCU/WCU you reserve, regardless of usage.

Cheaper at steady-state, requires auto-scaling to handle bursts, and can throttle requests if traffic exceeds capacity faster than scaling reacts. Default in 2026: start with on-demand for unpredictable workloads (new products, side-projects, internal tools). Switch to provisioned + auto-scaling when traffic is steady and predictable enough to justify the price cut, typically when monthly bill > ~$200 on a table.

You can switch billing modes once every 24 hours. Two things changed the arithmetic recently. On-demand throughput prices were cut roughly in half in November 2024, which moved the break-even a long way and made 'start on-demand, stay on-demand unless the bill argues otherwise' the correct default for most new tables.

And an on-demand table now accepts MaxReadRequestUnits and MaxWriteRequestUnits, which turns a runaway retry loop from a five-figure invoice into a throttle you can alarm on. Provisioned still wins for genuinely flat high-volume traffic, especially with reserved capacity bought for a year. The gotcha in either direction: throughput history is per partition, so a table that has never seen more than 2,000 WCU cannot instantly absorb 50,000, even in on-demand mode, and the first burst after a mode switch can throttle. Warm throughput, added in 2024, is how you declare that peak in advance instead of running a synthetic traffic ramp before a launch.

Q10

How do you write and read a single item?

BasicOperations

Answer

PutItem inserts or replaces an item entirely. UpdateItem modifies specific attributes without overwriting the rest. GetItem fetches a single item by its full primary key.

All three are O(1) operations. PutItem is destructive, if you only want to insert when no item exists, add `ConditionExpression: 'attribute_not_exists(PK)'`. This is the standard idempotent-create pattern.

UpdateItem is the one to reach for by default: it is an upsert, so it creates the item when the key is absent, and it only touches the attributes you name, which avoids the lost-update race where two concurrent PutItems clobber each other's fields. Every write accepts a ConditionExpression, and when the condition fails the SDK throws ConditionalCheckFailedException, which is a normal business outcome (duplicate signup, stale version) rather than an error you should blindly retry. ReturnValues: 'ALL_NEW' hands back the resulting item so you skip a follow-up read, and ReturnValuesOnConditionCheckFailure: 'ALL_OLD', added in 2023, returns the item as it actually was when the condition failed, which turns an opaque rejection into something you can debug.

GetItem returns an empty response rather than an error when the key does not exist, so the existence check is `if (!Item)`, not a try/catch. ProjectionExpression shrinks the response payload but not the RCU charge, because capacity is billed on the stored item size, not on what you asked for.

// Insert
await ddb.put({
  TableName: "Users",
  Item: { PK: "USER#42", name: "Asha", createdAt: "2026-05-12" },
  ConditionExpression: "attribute_not_exists(PK)", // fail if already exists
}).promise();

// Read
const { Item } = await ddb.get({
  TableName: "Users",
  Key: { PK: "USER#42" },
}).promise();

// Update specific fields
await ddb.update({
  TableName: "Users",
  Key: { PK: "USER#42" },
  UpdateExpression: "SET #n = :name, lastSeenAt = :ts",
  ExpressionAttributeNames: { "#n": "name" },
  ExpressionAttributeValues: { ":name": "Asha K.", ":ts": "2026-05-12T10:00Z" },
}).promise();
Q11

What is DynamoDB vs MongoDB, when would you pick one over the other?

BasicComparison

Answer

Both are NoSQL document stores, but they sit at opposite ends of the trade-off curve. DynamoDB: fully managed by AWS, serverless billing, single-digit-ms latency at scale, but rigid access patterns (you must model up front), limited query flexibility, AWS-only. MongoDB: rich query language (find, aggregation pipeline, $lookup joins), flexible secondary indexes, runs anywhere (Atlas, self-hosted, GCP/Azure), but you pay (and operate) for capacity even when idle, harder to scale beyond a single replica set.

Pick DynamoDB when: you're already on AWS, your access patterns are known and stable, you want zero-ops, you're serverless-first (Lambda + API Gateway). Pick MongoDB when: you need ad-hoc queries, your team prefers richer query syntax, you're multi-cloud or self-hosted, your data model is still evolving. In India in 2026, MongoDB is more common at established product companies; DynamoDB dominates serverless-first startups and any team running on Lambda.

A strong answer also names the operational differences. DynamoDB has no query planner, no EXPLAIN and no way to add an index that rescues a bad query at 3 AM, whereas MongoDB lets you createIndex on a hot field and recover in minutes. The flip side is that MongoDB gives you enough rope to run an unindexed aggregation that takes the primary down, while the worst DynamoDB does is throttle you and send an invoice.

Cost shape differs too: DynamoDB is per-request with no idle cost, Atlas bills per hour whether anyone is using it or not, so spiky low-volume workloads land cheaper on DynamoDB and steady high-volume ones often land cheaper on Mongo. Connections differ too: DynamoDB is HTTPS with IAM auth and no pool, so 1,000 concurrent Lambdas cannot exhaust it the way they exhaust MongoDB.

Q12

What is TTL in DynamoDB?

BasicFeatures

Answer

TTL (Time To Live) lets DynamoDB automatically delete items after an expiration timestamp. You enable TTL on a numeric attribute holding a Unix epoch (seconds). DynamoDB scans for expired items in the background and deletes them, for free, no WCU charges.

The deletion isn't immediate, items can stick around for up to 48 hours after expiry. Common uses: session tokens, OTPs, temporary cache entries, audit logs older than 90 days. Important detail: TTL deletes ARE reflected in DynamoDB Streams (with `eventName: 'REMOVE'` and `userIdentity` indicating it was the TTL service), so you can react to expiration in a Lambda, for example, archiving to S3 before deletion.

Operational details worth knowing: TTL is swept by a background process per partition, so the lag scales with table size and is measured in hours. Never treat it as a scheduler. Expired-but-not-yet-deleted items still occupy storage and still come back in Query and Scan results, so anything user-facing has to filter on expiresAt in the application as well.

The stream record for a TTL delete carries userIdentity with principalId 'dynamodb.amazonaws.com' and type 'Service', which is exactly how a Lambda tells an expiry apart from a real user delete. The deletes themselves are free, but the resulting GSI deletes and stream records are not entirely, so a table churning millions of expiries a day still shows up on the bill. The most common failure is enabling TTL on an attribute that some code paths forget to write: those items live forever, which is fine until a compliance review asks why 2022 session tokens are still in the table.

// Enable once on the table:
// aws dynamodb update-time-to-live --table-name Sessions \
//   --time-to-live-specification "Enabled=true,AttributeName=expiresAt"

await ddb.put({
  TableName: "Sessions",
  Item: {
    PK: "SESS#abc123",
    userId: 42,
    expiresAt: Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour
  },
}).promise();
Q13

Why does UpdateExpression 'SET name = :n' fail, and what is ExpressionAttributeNames for?

BasicExpressions

Answer

DynamoDB reserves around 570 words, and name, status, size, timestamp, data, year, count and type are all on the list. Use one directly in an UpdateExpression, ConditionExpression, ProjectionExpression or FilterExpression and the call fails with 'ValidationException: Invalid UpdateExpression: Attribute name is a reserved keyword; reserved keyword: name'. ExpressionAttributeNames is the escape hatch: you put a placeholder starting with # into the expression and map it to the real attribute name in the request body.

The same mechanism covers attribute names that the expression grammar cannot parse at all, anything containing a space, a hyphen, a dot or a leading digit, and it is how you safely address a nested path as #a.#b. Its sibling ExpressionAttributeValues does the mirror job for values with : placeholders, and keeping values out of the expression string is not just style, it is what stops user input from being concatenated into a query in the first place. Two failure modes to memorise: every name you declare must be used, or you get 'ValidationException: Value provided in ExpressionAttributeNames unused in expressions', and every placeholder you use must be declared, or the call fails with an invalid-key error. Because remembering the reserved list is a losing game, most teams either alias every application attribute unconditionally or let ElectroDB, the Java enhanced client or a similar layer build the expression, which removes the whole class of bug.

// Fails: ValidationException, "name" and "status" are reserved words.
// await ddb.update({ ..., UpdateExpression: "SET name = :n" });

await ddb.update({
  TableName: "Users",
  Key: { PK: "USER#42" },
  UpdateExpression: "SET #name = :name, #status = :status",
  ExpressionAttributeNames: {
    "#name": "name",
    "#status": "status",
  },
  ExpressionAttributeValues: {
    ":name": "Asha",
    ":status": "ACTIVE",
  },
  ReturnValues: "ALL_NEW",
}).promise();
💡 Pro Tip: Alias every attribute you touch with # by default. It costs nothing and you never have to check the reserved-word list again.
Q14

What do SET, REMOVE, ADD and DELETE do inside an UpdateExpression?

BasicExpressions

Answer

An UpdateExpression is built from up to four clauses, each holding several comma-separated actions, and the whole thing applies as one atomic write on the storage node. SET assigns: 'SET total = :t', arithmetic as 'SET stock = stock - :one', defaults as 'SET tries = if_not_exists(tries, :zero)', and append as 'SET history = list_append(if_not_exists(history, :empty), :new)'. REMOVE deletes attributes outright ('REMOVE tempToken, draft.body') and is also how you drop a list element by index ('REMOVE tags[2]'), which shifts the remaining elements down.

ADD is the legacy action and only works on top-level Numbers and Sets: on a Number it increments and creates the attribute at zero if it is missing, on a Set it unions members. DELETE only works on Sets and removes members. AWS's own documentation recommends SET over ADD for numbers; the one thing ADD still does more neatly is create-or-increment without if_not_exists.

Rules that trip people up: two actions in one expression may not touch overlapping document paths, or you get 'Invalid UpdateExpression: Two document paths overlap with each other'; arithmetic against a missing attribute fails rather than treating it as zero; and list_append on an attribute that does not exist fails unless you wrap it in if_not_exists. Pair any of these with a ConditionExpression and you have compare-and-swap semantics with no read at all.

// One atomic write using all four clauses.
await ddb.update({
  TableName: "App",
  Key: { PK: "ORDER#abc", SK: "METADATA" },
  UpdateExpression: [
    "SET #s = :shipped, updatedAt = :now",
    "ADD retryCount :one",
    "DELETE labels :drop",
    "REMOVE draftNote",
  ].join(" "),
  ConditionExpression: "#s = :pending",
  ExpressionAttributeNames: { "#s": "status" },
  ExpressionAttributeValues: {
    ":shipped": "SHIPPED",
    ":pending": "PENDING",
    ":now": new Date().toISOString(),
    ":one": 1,
    ":drop": new Set(["urgent"]),
  },
  ReturnValues: "ALL_NEW",
}).promise();

Key Points

  • SET assigns, does arithmetic, if_not_exists and list_append
  • REMOVE drops attributes and list elements by index
  • ADD is Numbers and Sets only; DELETE is Sets only
  • Clauses in one expression may not overlap document paths
Q15

What is single-table design and why is it the recommended DynamoDB pattern?

IntermediateData Modelling

Answer

Single-table design is the practice of storing multiple entity types, Users, Orders, OrderItems, Products, Reviews, in a single DynamoDB table, distinguished by their PK/SK patterns rather than by table boundaries. Popularised by Alex DeBrie and AWS Hero Rick Houlihan, it became canonical advice around 2018-2020. The reasoning: DynamoDB Query returns items from one partition in one round-trip, so if you co-locate related entities under the same PK, you can fetch an entire object graph (user + their last 10 orders + each order's items) in one Query instead of many GetItems.

This collapses what would be 10+ joins in SQL into a single millisecond-latency call. Cost goes down (fewer RCUs), latency goes down (one network hop), and you stay within DynamoDB's transactional limits. The trade-off is upfront design effort, you build a full 'access patterns' document before you create the table, and refactoring an access pattern later may require a backfill.

In interviews, expect to be handed an app domain (e.g. 'design Twitter') and asked to draw the table on a whiteboard with PK/SK examples for each entity. The honest counter-argument, which has gained ground since about 2022, is that single-table design optimises for a cost and latency profile most teams never reach while making onboarding, ad-hoc analytics and schema evolution harder, and that a junior team will produce a worse table than three obvious ones. AWS's own guidance now reads closer to 'co-locate what you fetch together' than 'everything in one table'. A strong answer holds both positions: one table for the entity cluster inside a service boundary, separate tables across service boundaries so ownership and IAM policies stay clean, and a named access pattern justifying every GSI.

// Single table for an e-commerce app
// PK              | SK                       | type    | ...
// USER#42         | PROFILE                  | User    | name, email
// USER#42         | ORDER#2026-05-12T10:00Z  | Order   | total, status
// USER#42         | ORDER#2026-05-13T09:30Z  | Order   | total, status
// ORDER#abc123    | ITEM#1                   | Item    | sku, qty
// ORDER#abc123    | ITEM#2                   | Item    | sku, qty
// PRODUCT#xyz     | METADATA                 | Product | title, price

// One query → user profile + all their orders:
await ddb.query({
  TableName: "App",
  KeyConditionExpression: "PK = :pk",
  ExpressionAttributeValues: { ":pk": "USER#42" },
}).promise();

Key Points

  • All entities live in one table, separated by PK/SK patterns
  • Pre-joins via Query on a shared PK, one call fetches a graph
  • Upfront access-pattern design is mandatory
  • Alex DeBrie's 'The DynamoDB Book' is the reference
Q16

What's the difference between an LSI and a GSI?

IntermediateIndexes

Answer

Local Secondary Index (LSI): shares the table's partition key, but uses a different sort key. Must be created at table creation, can't be added later, limited to 5 per table. Strong consistency is supported.

Stores up to 10 GB per partition key (a hard limit that bites at scale). Use case: re-sort items within a partition by a different field, e.g. sort a user's orders by total instead of by date. Global Secondary Index (GSI): has its OWN partition key and sort key, completely independent of the base table.

Can be added or dropped any time, up to 20 per table. Always eventually consistent. No 10 GB constraint.

Use case: query by a completely different access pattern, e.g. 'find all orders with status PENDING' (PK on GSI = status, SK = createdAt). In modern DynamoDB modelling, GSIs are the default, LSIs are rarely used because their inability to add/remove is a serious operational handicap. Plan for several GSIs in a single-table design, typically called GSI1, GSI2, etc., with generic key names (GSI1PK, GSI1SK) so multiple entity types can reuse the same index.

Key Points

  • LSI: same PK, different SK; created at table creation; max 5
  • GSI: independent PK + SK; add/drop any time; max 20
  • LSI supports strong consistency; GSI is eventually consistent
  • In practice, prefer GSIs
Q17

How do you avoid hot partitions in DynamoDB?

IntermediatePerformance

Answer

A hot partition is one that's receiving disproportionately more reads or writes than others, because every partition has a throughput ceiling (~3,000 RCU and 1,000 WCU per partition), a hot partition throttles even though the table has spare capacity overall. Causes: a partition key with low cardinality ('status' = active for 99% of items), or with high temporal skew (a date-only PK on time-series data, where all of today's writes hit the same partition). Fixes: (1) Choose high-cardinality keys, userId, orderId, deviceId. (2) For write-hot keys, add a random suffix to spread writes (write-sharding): `EVENT#2026-05-12#0` through `EVENT#2026-05-12#9`.

On read, query all 10 shards in parallel and merge. (3) For read-hot items (the 'celebrity user' problem), front DynamoDB with DAX or an application-level cache. (4) Use adaptive capacity, since 2019, DynamoDB automatically isolates hot partitions and gives them more throughput, which masks many hot-partition problems but doesn't eliminate them. CloudWatch metric to watch: `ConsumedReadCapacityUnits` and `ConsumedWriteCapacityUnits` per partition (via Contributor Insights). What this looks like during an incident: average latency is fine, p99 is terrible, and CloudWatch shows ThrottledRequests climbing while ConsumedWriteCapacityUnits sits well below the provisioned figure, because table-level metrics are averages across partitions.

Enable CloudWatch Contributor Insights on the table and it names the exact partition keys taking the traffic, which usually ends the argument in one screenshot. Note the metric distinction: ThrottledRequests counts requests where at least one item was throttled, while WriteThrottleEvents counts item-level events, so a BatchWriteItem can move one and not the other.

// Write-sharding a date-bucketed key across 10 partitions.
const SHARDS = 10;
const day = "2026-05-12";

await ddb.put({
  TableName: "Events",
  Item: {
    PK: `EVENT#${day}#${Math.floor(Math.random() * SHARDS)}`,
    SK: `${new Date().toISOString()}#${eventId}`,
    payload,
  },
}).promise();

// Read: fan out across every shard, then merge.
const pages = await Promise.all(
  Array.from({ length: SHARDS }, (_, i) =>
    ddb.query({
      TableName: "Events",
      KeyConditionExpression: "PK = :pk",
      ExpressionAttributeValues: { ":pk": `EVENT#${day}#${i}` },
    }).promise()
  )
);
const items = pages.flatMap((p) => p.Items).sort((a, b) => a.SK.localeCompare(b.SK));
Q18

What are DynamoDB Streams and how do you process them with Lambda?

IntermediateStreams

Answer

A DynamoDB Stream is an ordered, time-ordered log of every INSERT/MODIFY/DELETE on a table, like a Kafka topic but managed by AWS. You enable it per table, choose what to capture (KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, or NEW_AND_OLD_IMAGES), and AWS retains the records for 24 hours. The most common consumer is a Lambda function, you set up an event source mapping and Lambda polls the stream, invoking your function in batches of up to 10,000 records.

Use cases: maintaining derived/denormalised views (mirror a table into OpenSearch for search), invalidating cache entries, triggering side effects (send email when order placed), cross-region replication. Important gotchas: streams guarantee 'at-least-once' delivery and per-partition ordering only, across partitions, ordering isn't guaranteed, so design idempotent consumers. If your Lambda errors and retries indefinitely, you can stall a shard, always configure a DLQ.

Mechanics worth naming: a stream has one shard per table partition, and Lambda runs at most one concurrent invocation per shard, so stream throughput scales with partition count, not with reserved concurrency. That is also why a poison-pill record blocks everything behind it. Set BisectBatchOnFunctionError, MaximumRetryAttempts and DestinationConfig (an on-failure SQS queue or SNS topic) on the event source mapping, or one bad record stalls that partition's changes for the full 24-hour retention and then drops them silently.

ParallelizationFactor, up to 10, runs several batches per shard while preserving order within a partition key. If you need more than two consumers or retention beyond 24 hours, use Kinesis Data Streams for DynamoDB instead: it holds records for up to a year, at the price of giving up the strict per-item ordering guarantee.

// Lambda handler reacting to a stream
export const handler = async (event) => {
  for (const record of event.Records) {
    if (record.eventName === "INSERT") {
      const newItem = unmarshall(record.dynamodb.NewImage);
      if (newItem.PK?.startsWith("ORDER#")) {
        await sendOrderConfirmationEmail(newItem);
      }
    }
  }
};
Q19

What is DAX and when should you use it?

IntermediateCaching

Answer

DynamoDB Accelerator (DAX) is an in-memory cache that sits in front of DynamoDB inside your VPC. It's a fully-managed write-through cache cluster with microsecond read latency (vs ~5-10 ms direct). Compatible with the DynamoDB API, you change the SDK endpoint, your application code doesn't change.

Use DAX when: you have read-heavy hot keys (a small set of items getting most of the traffic), eventual consistency is acceptable (DAX item cache TTL defaults to 5 min), and you can spare the running cost (DAX is a provisioned cluster, not serverless). Skip DAX when: writes dominate (DAX adds latency to writes), your queries are diverse (Query cache TTL is short and per-query), or you want to stay fully serverless. Common alternative: ElastiCache (Redis) as an application-level cache.

In 2026, DAX is most useful for legacy DynamoDB apps that can't easily refactor caching into the application layer, newer apps tend to use Redis instead. Operational facts decide it. A DAX cluster is EC2 instances inside your VPC: it needs subnets, security groups and at least three nodes across Availability Zones in production, it is unreachable from outside the VPC, and Lambda functions must be VPC-attached to use it, reintroducing the networking you left behind.

DAX keeps two caches with separate TTLs: an item cache for GetItem and BatchGetItem, and a query cache for Query and Scan results. A write invalidates the item but not every query result containing it, so a read-after-write served from the query cache can return the old list, which is the bug people spend a day on. Writes are write-through and synchronous, so DAX never protects you from write throttling, only from read cost and latency.

Q20

How do you implement transactions in DynamoDB?

IntermediateTransactions

Answer

DynamoDB supports ACID transactions across up to 100 items in 4 MB of data, within a single AWS account/region. Two APIs: TransactWriteItems (group of up to 100 Put/Update/Delete/ConditionCheck operations, all succeed or all roll back) and TransactGetItems (consistent read across up to 100 items at one snapshot). Each item participating in a transaction costs 2x its normal RCU/WCU.

Use cases: financial moves (debit one account, credit another), maintaining counter consistency, atomic state transitions. Don't use transactions for everything, they're slower (extra round-trip), more expensive, and have a higher chance of TransactionCanceledException under contention. For most cases, a ConditionExpression on a single PutItem/UpdateItem is enough.

Failure handling is where interviews actually go. TransactWriteItems throws TransactionCanceledException with a CancellationReasons array holding one entry per operation, in order, and the reasons are specific: ConditionalCheckFailed, TransactionConflict (another transaction touched the same item), ItemCollectionSizeLimitExceeded, ProvisionedThroughputExceeded, or ValidationError. Only a truncated summary lands in the exception message, so log the whole array or you will debug blind.

TransactionConflict is retryable with jitter; ConditionalCheckFailed is not, because your business rule said no. Two constraints people forget: no two operations in one transaction may target the same item, and ClientRequestToken gives you a 10-minute idempotency window so a retry after a network timeout does not apply the transaction twice. Transactions also do not span regions, so on a Global Table an atomic pair is atomic only in the region that accepted it, and the replicas receive the two writes independently.

// Atomic 'create order + decrement inventory'
await ddb.transactWrite({
  TransactItems: [
    {
      Put: {
        TableName: "App",
        Item: { PK: "ORDER#abc", SK: "METADATA", userId: 42, sku: "SKU#1" },
        ConditionExpression: "attribute_not_exists(PK)",
      },
    },
    {
      Update: {
        TableName: "App",
        Key: { PK: "SKU#1", SK: "INVENTORY" },
        UpdateExpression: "SET stock = stock - :one",
        ConditionExpression: "stock > :zero",
        ExpressionAttributeValues: { ":one": 1, ":zero": 0 },
      },
    },
  ],
}).promise();
Q21

How do you handle pagination in DynamoDB?

IntermediateReads

Answer

DynamoDB returns results in pages capped at 1 MB. When a Query or Scan exceeds 1 MB (or hits your Limit), the response includes `LastEvaluatedKey`, pass this as `ExclusiveStartKey` in the next call to continue. There's no SQL-style OFFSET, DynamoDB pagination is forward-only and key-based, which is actually faster than offset-based pagination for large datasets.

For client-facing pagination, base64-encode the `LastEvaluatedKey` as an opaque cursor, never expose the raw key, since attackers could craft cursors that skip ahead. Common gotcha: `Limit` is a HINT, not a guarantee, DynamoDB may return fewer items if a Filter excludes most of them or if the 1 MB limit hits first. Always loop until `LastEvaluatedKey` is undefined OR you've collected enough items.

The subtle bug is that LastEvaluatedKey is a complete primary key, and on a GSI query it contains both the index key and the base-table key, so a cursor is index-specific and breaks the moment you change IndexName or flip ScanIndexForward. Sign or encrypt the cursor rather than only base64-encoding it if the key contains anything the user should not see, because base64 is not obfuscation. There is also no total count without reading everything, so 'page 7 of 219' is not a DynamoDB-shaped UI: infinite scroll or next/previous is.

To offer a previous page, reverse ScanIndexForward and keep the first key of the current page. And because Limit caps items examined rather than items returned, an endpoint promising 20 results alongside a FilterExpression has to loop internally, otherwise it returns three items plus a cursor and the client concludes the list ended.

async function* paginate(params) {
  let ExclusiveStartKey;
  do {
    const res = await ddb.query({ ...params, ExclusiveStartKey }).promise();
    for (const item of res.Items) yield item;
    ExclusiveStartKey = res.LastEvaluatedKey;
  } while (ExclusiveStartKey);
}

for await (const order of paginate({
  TableName: "App",
  KeyConditionExpression: "PK = :pk",
  ExpressionAttributeValues: { ":pk": "USER#42" },
})) {
  console.log(order);
}
Q22

What is the BatchGetItem and BatchWriteItem API?

IntermediateOperations

Answer

BatchGetItem fetches up to 100 items (or 16 MB total) across multiple tables in one request. BatchWriteItem performs up to 25 Put or Delete operations (16 MB total). Both are NOT atomic, partial failures are normal; the response includes `UnprocessedKeys` or `UnprocessedItems` for things that didn't make it (usually due to throttling).

You must implement retry-with-backoff on those. Batch APIs save network round-trips but don't reduce capacity consumption, each item still consumes its full RCU/WCU. Use cases: bulk lookups by ID list (e.g. enriching a feed with author profiles), bulk imports.

For atomic multi-item operations, use TransactWriteItems instead. Two behaviours matter in production. First, BatchWriteItem supports only Put and Delete, with no ConditionExpression and no UpdateItem, so anything that needs a guard has to be an individual write or a transaction, and two operations on the same key in one call are rejected outright with a ValidationException about duplicate keys.

Second, UnprocessedItems is not an error: the call returns HTTP 200, and code that ignores the field loses writes silently. That is the single most common data-loss bug in DynamoDB applications, a bulk import that 'succeeds' while 4% of rows are missing. The retry loop should back off exponentially with jitter and, if the same items keep coming back, slow the producer down rather than hammering a throttled partition.

BatchGetItem behaves the same way with UnprocessedKeys. Nothing in the AWS SDK v3 DocumentClient handles this for you, which is why most teams wrap both calls once and never call them raw again.

// BatchWriteItem returns 200 with UnprocessedItems. You MUST retry them.
async function batchWriteAll(TableName, items) {
  let requests = items.map((Item) => ({ PutRequest: { Item } }));
  let attempt = 0;

  while (requests.length) {
    const chunk = requests.slice(0, 25);
    const res = await ddb.batchWrite({
      RequestItems: { [TableName]: chunk },
    }).promise();

    const left = res.UnprocessedItems?.[TableName] ?? [];
    requests = left.concat(requests.slice(25));

    if (left.length) {
      attempt += 1;
      if (attempt > 8) throw new Error("persistent throttling on " + TableName);
      const backoff = Math.min(2 ** attempt * 50, 5000);
      await new Promise((r) => setTimeout(r, backoff + Math.random() * 100));
    } else {
      attempt = 0;
    }
  }
}
💡 Pro Tip: Always retry UnprocessedItems with exponential backoff, the AWS SDK doesn't do this for you in batch operations.
Q23

How does auto-scaling work in provisioned mode?

IntermediateCapacity

Answer

Auto-scaling watches consumed capacity vs provisioned capacity and adjusts up or down to maintain a target utilization (default 70%). It uses Application Auto Scaling under the hood, which means it's actually CloudWatch alarms calling DynamoDB's UpdateTable API. The catch: it takes 2-5 minutes to react, so it doesn't protect you from sudden traffic spikes, only from sustained shifts.

If you launch a Black Friday sale at 10 AM sharp, you'll get throttled until auto-scaling catches up. Workarounds: schedule a manual capacity bump before known events, set a high minimum capacity, or switch the table to on-demand for the event. Auto-scaling can scale UP up to your max limit unlimited times per day, but scale DOWN events are throttled (4 times per day in 2026).

For unpredictable traffic, on-demand mode is usually a better fit despite the price premium. Concretely, the scalable dimensions you register with Application Auto Scaling are table:ReadCapacityUnits, table:WriteCapacityUnits and the matching index:ReadCapacityUnits and index:WriteCapacityUnits, each with its own minimum, maximum and target. Forgetting the index dimension is a classic outage: the table scales, the GSI does not, index writes throttle, and because a throttled GSI back-pressures the base table the whole table starts rejecting writes.

The scale-out alarm needs consecutive one-minute datapoints above target before it fires, which is where most of the reaction delay comes from, and the scale-in cooldown is what stops the table oscillating. If your traffic has a known daily shape rather than random spikes, scheduled scaling is more reliable than target tracking and much cheaper than provisioning for the peak all day.

Q24

What is adaptive capacity?

IntermediateCapacity

Answer

Adaptive capacity is a behind-the-scenes feature (always on, no toggle) where DynamoDB automatically rebalances throughput between partitions when traffic skews. Originally (pre-2018), each partition got 1/N of the table's provisioned capacity, period, so a hot partition would throttle even if other partitions sat idle. With adaptive capacity, DynamoDB can re-allocate up to the full table-level capacity to a single partition, and within ~30 seconds isolate a hot partition onto its own physical hardware.

This masks a lot of hot-partition problems that used to require write-sharding. It doesn't change the per-partition limit (~3,000 RCU / 1,000 WCU max), so for truly extreme hot spots you still need to spread the key. In interviews, mentioning adaptive capacity along with the per-partition limits shows you've kept up with how DynamoDB has evolved.

Separate the two mechanisms when you answer. Instant adaptive capacity, shipped in 2019, reallocates the table's unused capacity to a busy partition immediately. Partition-level isolation, where DynamoDB splits a hot partition onto its own hardware, takes minutes and only happens when the skew persists, which is why a suddenly viral item still throttles for the first few minutes.

Neither helps when the traffic concentrates on a single item rather than a single partition, because an item cannot be split, so the celebrity-item problem is solved by caching or by sharding the key, never by waiting. On-demand tables get the same machinery plus a doubling rule: a table absorbs roughly twice its previous peak instantly and throttles beyond that until partitions warm up, which is precisely the gap that the warm throughput setting exists to close before a launch.

Q25

How do you query by attributes that aren't part of the key?

IntermediateIndexes

Answer

You don't, directly. Three options, in increasing order of cost: (1) **Use a GSI**: create a Global Secondary Index where the new attribute is the PK (or PK + SK). Queries on the GSI are O(1), exactly like base-table queries.

Adds ongoing storage + WCU cost (every write to the base table replicates to the GSI). This is the standard answer. (2) **Use FilterExpression on a Scan**: works without modelling changes but reads the whole table, fine for small tables, terrible at scale. (3) **PartiQL with a non-key attribute**, looks like SQL, but underneath, if the WHERE clause doesn't match an indexed attribute, PartiQL just does a Scan. Don't be fooled by the SQL syntax.

In single-table design, plan your GSIs alongside your access patterns from the start, usually 2-4 GSIs are enough to cover most apps. Two refinements a senior interviewer expects to hear. Use sparse indexes deliberately: DynamoDB only projects an item into a GSI if the item actually carries that index's key attributes, so writing GSI1PK only on the rows you care about (unprocessed jobs, flagged accounts, failed payments) gives you an index with thousands of rows instead of millions, and clearing the attribute removes the row from the queue for free.

And choose the projection consciously between KEYS_ONLY, INCLUDE with a named attribute list, and ALL: ALL is convenient but duplicates every attribute into index storage and index write cost, while KEYS_ONLY forces a second GetItem per row. For genuinely arbitrary search across many attributes, stop fighting the model and stream the table into OpenSearch, which the zero-ETL integration now does without a Lambda of your own.

// GSI lookup: find all PENDING orders, newest first
// GSI1PK = STATUS#PENDING, GSI1SK = createdAt

await ddb.query({
  TableName: "App",
  IndexName: "GSI1",
  KeyConditionExpression: "GSI1PK = :s",
  ExpressionAttributeValues: { ":s": "STATUS#PENDING" },
  ScanIndexForward: false,
  Limit: 50,
}).promise();
Q26

How do you do an atomic counter in DynamoDB?

IntermediateOperations

Answer

Use UpdateItem with an ADD action (or `SET attr = attr + :n`). Both are atomic, DynamoDB handles concurrent increments without race conditions. There's no read-then-write, the database does the math on the storage node.

Use case: page view counters, vote counts, inventory. Two gotchas: (1) ADD only works on numbers and sets, SET works on any expression. (2) For high-throughput counters (1000+ ops/sec on one key), you'll hit the partition throughput limit; shard the counter across N items and sum on read. The property that matters is that the arithmetic happens on the storage node with no read, so it is correct under concurrency and cannot be lost the way read-modify-write can.

What it is not is conditional: for 'decrement but never below zero', pair it with ConditionExpression 'stock >= :n' and treat ConditionalCheckFailedException as the out-of-stock branch. ReturnValues: 'UPDATED_NEW' gives you the new value in the same call, which is how you build a rate limiter or a sequence generator without a second read. Two production notes.

The write is not idempotent by itself: an increment retried after a network timeout increments twice, so anything financial needs an idempotency-key item written in the same TransactWriteItems rather than a bare ADD. And the sharded variant trades read cost for write throughput, since N shards means N times the read capacity per aggregate, so cache the total or roll it up on a schedule instead of fanning out on every page view.

// Simple atomic increment
await ddb.update({
  TableName: "App",
  Key: { PK: "PRODUCT#abc", SK: "VIEWS" },
  UpdateExpression: "ADD viewCount :one",
  ExpressionAttributeValues: { ":one": 1 },
}).promise();

// Sharded counter for hot keys: write to a random shard
const shard = Math.floor(Math.random() * 10);
await ddb.update({
  TableName: "App",
  Key: { PK: "PRODUCT#abc", SK: `VIEWS#${shard}` },
  UpdateExpression: "ADD viewCount :one",
  ExpressionAttributeValues: { ":one": 1 },
}).promise();
// On read: query all 10 shards and sum.
Q27

What is PartiQL in DynamoDB?

IntermediateQuerying

Answer

PartiQL is a SQL-compatible query language Amazon added to DynamoDB in 2020. You can write SELECT, INSERT, UPDATE, DELETE statements that look familiar to SQL developers. Helpful for ad-hoc exploration in the console, for one-off scripts, and for teams transitioning from SQL who want to onboard gradually.

The crucial caveat: PartiQL doesn't add new query capabilities, it's syntactic sugar over the existing Query/Scan/Put APIs. A `SELECT * FROM Orders WHERE status = 'PENDING'` without a GSI on status becomes a full Scan, just like before. Use PartiQL for: ad-hoc reads in the AWS console, queries against your single-table design where the SQL form is easier to read than the JSON form.

Don't use PartiQL when: you need fine-grained control over consistency, capacity, or pagination, the native APIs are clearer. Practical limits to quote: ExecuteStatement returns a single 1 MB page plus a NextToken, BatchExecuteStatement caps at 25 statements, and ExecuteTransaction gives you PartiQL over the transactional API with the same 100-item ceiling. Querying an index requires naming it in the FROM clause, as in SELECT * FROM "App"."GSI1" WHERE GSI1PK = ?, and there is no join, no GROUP BY and no aggregate function.

The real risk is operational rather than syntactic: PartiQL makes a full table Scan look like a harmless one-line query, so an engineer exploring production from the console can burn a day of provisioned capacity in a minute and throttle live traffic while doing it. Teams that allow PartiQL usually pair it with an IAM policy denying dynamodb:PartiQLSelect on production tables, and point analysts at an S3 export queried through Athena instead.

// PartiQL via the SDK
import { ExecuteStatementCommand } from "@aws-sdk/client-dynamodb";

const res = await client.send(new ExecuteStatementCommand({
  Statement: `SELECT * FROM "App" WHERE PK = ?`,
  Parameters: [{ S: "USER#42" }],
}));
Q28

How do you test code that talks to DynamoDB?

IntermediateTesting

Answer

There are three realistic options and the choice is fidelity versus speed. DynamoDB Local is AWS's own emulator, shipped as a JAR and as the amazon/dynamodb-local Docker image, and run with -inMemory -sharedDb it starts in a second and resets between suites. It implements key schema, expressions, transactions, conditions and pagination faithfully enough to catch real bugs in CI without an AWS account.

It is not the service, though: there is no capacity model, no throttling, no adaptive behaviour, GSIs update synchronously instead of eventually, and streams and TTL behave differently, so a test suite that passes locally can still meet ValidationException or eventual-consistency bugs in production. moto for Python and LocalStack are lighter and easier to run per test, at the cost of occasional gaps in expression support that produce failures you cannot reproduce against the real service. The third option is a throwaway real table per CI run, named with the branch and a random suffix and deleted afterwards: slower, needs credentials in CI, but it is the only way to test IAM policies, stream delivery, GSI lag and TTL for real. The split most teams settle on is DynamoDB Local in Docker for unit tests, with the table created from the same CDK or CloudFormation definition as production so the key schema cannot drift, plus a small integration suite against a real table on merges to the main branch. Whatever you choose, do not mock the SDK client itself: asserting that send() was called proves your test double works and nothing else.

# docker-compose.yml
services:
  ddb:
    image: amazon/dynamodb-local:latest
    command: -jar DynamoDBLocal.jar -inMemory -sharedDb
    ports: ["8000:8000"]

// test/ddb.ts, point the real SDK at the local endpoint
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";

export const doc = DynamoDBDocumentClient.from(
  new DynamoDBClient({
    endpoint: process.env.DDB_ENDPOINT ?? "http://localhost:8000",
    region: "ap-south-1",
    credentials: { accessKeyId: "local", secretAccessKey: "local" },
  })
);

Key Points

  • DynamoDB Local for unit tests, real table for integration tests
  • Local has no capacity model and GSIs update synchronously
  • Create the test table from the same IaC definition as production
  • Never mock the SDK client, you end up testing the mock
Q29

How do you implement optimistic locking, and when does ConditionalCheckFailedException mean 'retry'?

IntermediateConcurrency

Answer

Optimistic locking means every item carries a version number and every write asserts the version it read. The update sets version = :next under ConditionExpression 'version = :current', so if another writer got there first the condition fails, DynamoDB rejects the write with ConditionalCheckFailedException, and you re-read and retry. Nothing is ever locked, hence optimistic: you pay only when there is a real conflict.

This is the right default in DynamoDB because a pessimistic lock needs a second item, a lease and a heartbeat, and still is not safe against a paused process. The higher-level AWS clients do it for you: @DynamoDBVersionAttribute in the v1 Java mapper, @DynamoDbVersionAttribute in the v2 enhanced client, and the equivalent in the .NET object persistence model, all raising ConditionalCheckFailedException on a mismatch. What goes wrong in production: one legacy code path does a blind PutItem and resets the version, so conflicts stop being detected; a retry reuses the stale in-memory item instead of re-reading, so it fails forever; and an unbounded retry loop on a genuinely hot item turns a conflict into a thundering herd, so bound the attempts and add jitter.

Since 2023 you can pass ReturnValuesOnConditionCheckFailure: 'ALL_OLD' and get the current item back inside the exception, which removes the extra read from the retry path. The follow-up worth pre-empting is when this is the wrong tool, and the answer is high-contention counters: an atomic ADD has no conflict to resolve and no retry loop at all.

async function updateWithVersion(key, mutate, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    const { Item } = await ddb.get({ TableName: "App", Key: key }).promise();
    if (!Item) throw new Error("not found");

    const next = mutate({ ...Item });
    try {
      await ddb.put({
        TableName: "App",
        Item: { ...next, version: Item.version + 1 },
        ConditionExpression: "version = :v",
        ExpressionAttributeValues: { ":v": Item.version },
      }).promise();
      return next;
    } catch (err) {
      if (err.name !== "ConditionalCheckFailedException") throw err;
      // Someone else won. Re-read (next loop) and back off with jitter.
      await new Promise((r) => setTimeout(r, 2 ** i * 20 + Math.random() * 40));
    }
  }
  throw new Error("too much contention on " + JSON.stringify(key));
}
Q30

What is the difference between PITR, on-demand backups, and Export to S3?

IntermediateOperations

Answer

Three separate features doing three different jobs. Point-in-time recovery is a continuous backup you switch on per table; once enabled you can restore to any second within the retention window, up to 35 days, and since 2024 that window is configurable downward to save cost. The critical detail is that a restore always creates a NEW table: you cannot restore in place, and the restored table arrives without auto-scaling policies, without TTL configuration, without Streams and with its indexes rebuilt, so your infrastructure-as-code has to reapply all of that and your application has to be repointed.

On-demand backups are explicit full snapshots that live until you delete them, and routing them through AWS Backup gives you cross-account and cross-region copies plus a vault policy, which is usually what an auditor actually wants. Export to S3 writes DynamoDB JSON or Amazon Ion to a bucket and consumes zero read capacity because it reads the PITR backup rather than the live table; incremental export, added in 2023, writes only the changes in a time window, which is how you feed a warehouse cheaply. Details that matter mid-incident: restore duration scales with table size and runs from tens of minutes to hours, so your recovery time objective is not zero, and none of these protect a table that someone deleted if deletion protection was off and the only backup lived in the same account. The realistic setup is PITR on every production table plus AWS Backup into a separate account.

# Turn on the two things every production table should have
aws dynamodb update-continuous-backups --table-name App \
  --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true

aws dynamodb update-table --table-name App --deletion-protection-enabled

# Restore to a moment before the bad deploy (creates a NEW table)
aws dynamodb restore-table-to-point-in-time \
  --source-table-name App \
  --target-table-name App-restore-2026-05-12 \
  --restore-date-time 2026-05-12T09:45:00+05:30

# Analytics without touching production capacity
aws dynamodb export-table-to-point-in-time \
  --table-arn arn:aws:dynamodb:ap-south-1:111122223333:table/App \
  --s3-bucket my-exports --export-format DYNAMODB_JSON
💡 Pro Tip: Rehearse the restore. The first time you discover that PITR drops TTL, Streams and auto-scaling settings should not be during an incident.
Q31

Why can a throttled GSI break writes to the base table?

IntermediateIndexes

Answer

Every write to the base table that changes an indexed attribute has to be replicated into each Global Secondary Index, and that replication consumes the index's own write capacity, billed at the size of the projected index item. In provisioned mode, if the GSI does not have enough WCU to keep up, DynamoDB will not let the index drift arbitrarily: it back-pressures the base table, and base-table writes start failing with ProvisionedThroughputExceededException even though the base table itself has spare capacity. This is the classic 3 AM DynamoDB incident.

The signature in CloudWatch is ThrottledRequests on the table with ConsumedWriteCapacityUnits comfortably under the provisioned line, while the same metric filtered by the GlobalSecondaryIndexName dimension is pinned at its limit. The cause is almost always one of three things: auto-scaling registered on the table but not on the index, an index partition key with low cardinality (status, tenantId, a boolean flag) so every index write lands on one partition, or a projection of ALL on a wide item so each base write copies several KB into the index. Fix in that order: register scaling on the index or move the table to on-demand, narrow the projection to KEYS_ONLY or INCLUDE, then re-key the index with a composite or sharded partition key. The design rule that prevents the whole class of incident is simple: a GSI partition key should have cardinality at least as high as the base table's, and you never index a low-cardinality attribute on a write-heavy table without sharding it.

# The step teams forget: scaling on the INDEX, not just the table
aws application-autoscaling register-scalable-target \
  --service-namespace dynamodb \
  --resource-id "table/App/index/GSI1" \
  --scalable-dimension "dynamodb:index:WriteCapacityUnits" \
  --min-capacity 10 --max-capacity 4000

aws application-autoscaling put-scaling-policy \
  --service-namespace dynamodb \
  --resource-id "table/App/index/GSI1" \
  --scalable-dimension "dynamodb:index:WriteCapacityUnits" \
  --policy-name GSI1WriteScaling --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 70.0,
    "PredefinedMetricSpecification": {
      "PredefinedMetricType": "DynamoDBWriteCapacityUtilization"
    }
  }'

Key Points

  • A GSI that cannot keep up back-pressures base-table writes
  • Symptom: table ThrottledRequests with table capacity to spare
  • Causes: unscaled index, low-cardinality index PK, ALL projection
  • GSI partition key cardinality must match the base table's
Q32

What actually drives a DynamoDB bill, and which levers cut it?

IntermediateCost

Answer

Four line items: writes, reads, storage, and everything derived from them (GSIs, Global Tables replicas, backups, streams). Writes usually dominate, and the biggest multiplier is index count, because one base write with four ALL-projection GSIs is five billed writes. Reads are cheaper than people fear unless something is Scanning, and the giveaway there is a ScannedCount far above Count in the response.

Storage is a small per-GB-month charge that stays trivial until a table with no TTL reaches terabytes, at which point the Standard-Infrequent Access table class cuts storage substantially in exchange for higher per-request pricing, which is right for audit logs and wrong for anything hot. The levers, roughly in the order they pay off: switch on TTL and stop keeping data by accident; narrow GSI projections from ALL to KEYS_ONLY or INCLUDE and delete indexes nothing queries; shorten attribute names on high-volume items, since names are stored per item and count toward the 1 KB write unit; batch tiny related records into fewer larger items where the access pattern allows; leave spiky tables on on-demand, which got roughly 50% cheaper in November 2024, and move genuinely flat ones to provisioned with a year of reserved capacity; and replace nightly Scans with Export to S3 plus Athena. Diagnose before you optimise: Cost Explorer grouped by usage type tells you which of the four line items is the problem, and Contributor Insights tells you which keys are generating it.

# Storage-heavy archive table: switch table class, expect higher request cost
aws dynamodb update-table --table-name AuditLog \
  --table-class STANDARD_INFREQUENT_ACCESS

# Stop paying to store data nobody reads
aws dynamodb update-time-to-live --table-name AuditLog \
  --time-to-live-specification "Enabled=true,AttributeName=expiresAt"

# Put a ceiling on an on-demand table so a retry storm cannot bankrupt you
aws dynamodb update-table --table-name App \
  --on-demand-throughput MaxReadRequestUnits=40000,MaxWriteRequestUnits=20000

# Find the expensive keys instead of guessing
aws dynamodb update-contributor-insights --table-name App \
  --contributor-insights-action ENABLE
Q33

How do you design a single-table schema for a complex app, walk through Twitter as an example.

AdvancedData Modelling

Answer

Step 1: list every access pattern (read AND write). For Twitter: (a) get user profile, (b) get user's tweets newest first, (c) post a tweet, (d) get a single tweet by ID, (e) get replies to a tweet, (f) get user's followers, (g) get user's following list, (h) get the home timeline. Step 2: design the PK/SK and overloaded GSIs to satisfy each pattern with at most one Query. A common shape:

• User profile: PK=USER#<id>, SK=PROFILE, attributes name, bio, joinedAt. • Tweet: PK=USER#<authorId>, SK=TWEET#<isoTimestamp>#<tweetId>, read pattern (b) becomes Query on PK=USER#<id> and SK begins_with TWEET#. • Tweet by ID: GSI1PK=TWEET#<tweetId>, GSI1SK=METADATA, read pattern (d). • Replies: PK=TWEET#<parentId>, SK=REPLY#<isoTimestamp>#<replyId>, pattern (e). • Follow edge: PK=USER#<followerId>, SK=FOLLOWS#<targetId>, plus GSI1PK=USER#<targetId>, GSI1SK=FOLLOWERS#<followerId>, patterns (f) and (g) using the same index, just swap perspective. • Home timeline: a fan-out-on-write pattern, when user X tweets, write a copy of the tweet under PK=TIMELINE#<followerId>, SK=TWEET#<timestamp> for each follower. This is what makes the home-timeline read just a Query rather than aggregating across N authors. (For celebrity users with 10M+ followers, you'd hybrid this with fan-out-on-read.) Always end the design by tracing each access pattern back to a Query, if any requires a Scan or multiple round-trips, iterate.

Key Points

  • Start with the access-patterns list, not the schema
  • Overload GSIs, same index serves multiple entity types
  • Denormalise aggressively, duplicates are cheap, joins aren't
  • Fan-out-on-write for timelines; hybrid for celebrity users
Q34

How do Global Tables provide multi-region replication, and what are the trade-offs?

AdvancedMulti-region

Answer

Global Tables turn a regular DynamoDB table into a multi-region, multi-active replicated table. You add replicas in additional AWS regions, every write to any region is automatically replicated to the others, typically within 1 second. Reads stay local to each region (single-digit-ms anywhere in the world).

The replication mechanism: DynamoDB Streams + an AWS-managed replicator service. Conflict resolution is last-writer-wins based on timestamps, which is the major trade-off, concurrent writes to the same item from different regions can lose the earlier one silently. Mitigation strategies: route each user's writes consistently to one 'home' region via Route 53 latency-based routing + sticky session, or use a conflict-resolution attribute (e.g., a version counter) and reconcile in application code via Streams.

Cost trade-off: you pay full WCU/RCU in every region, plus replication WCU charges. Use Global Tables for: globally distributed user bases with regional read locality (gaming, social, IoT), DR/active-active topology for resilience. Skip when: you have a single dominant region and tolerate normal cross-region read latency.

Two changes belong in a 2026 answer. Replicated writes on global tables got roughly 67% cheaper in November 2024, which removed much of the cost objection. And multi-Region strong consistency, announced at re:Invent 2024 and generally available during 2025, adds an opt-in mode where a write is acknowledged only once it is durable in a second region, so any replica read returns the latest committed value.

You pay for that in write latency measured in cross-region round-trips, and it is limited to specific region groups, so it belongs on the ledger table and not on the session table. Operationally, adding a replica is online but backfilling a large table takes hours, and replicas need identical GSIs.

Q35

How would you migrate a write-heavy 100 TB MongoDB collection to DynamoDB without downtime?

AdvancedMigration

Answer

Three-phase pattern. **Phase 1: dual-write**. Add code to the application that writes every change to both MongoDB (primary) and DynamoDB (shadow). Use a feature flag so you can roll forward and back.

Read traffic still goes to MongoDB. Validate writes are arriving in DynamoDB with matching values, handle write failures gracefully (queue for retry, don't break the request). **Phase 2: backfill**. Use AWS Glue or DMS to bulk-export historical MongoDB data, transform to your DynamoDB schema, and import via parallel batch writes (typically using the DynamoDB Import from S3 feature, which is much cheaper than live writes).

On a 100 TB dataset, plan for days-to-weeks of backfill. Reconcile by sampling random keys and diffing. **Phase 3: cutover**. Flip reads to DynamoDB behind a percentage flag, 1%, 10%, 50%, 100%, with rollback ready.

Once stable, drop the dual-write and decommission MongoDB. Risks to call out in an interview: schema translation (Mongo's flexible nested docs may exceed DynamoDB's 400 KB item limit, split if so), hot-key risk (a Mongo collection scanned 'evenly' may hit one PK in DynamoDB), capacity planning (write 100 TB of items on-demand will be expensive, provisioned + temporary increase is cheaper).

Q36

How do you implement leader election / distributed locks on DynamoDB?

AdvancedPatterns

Answer

DynamoDB's strongly-consistent ConditionExpression makes it a passable lock manager. Pattern: a 'Locks' table with PK=lockName, and attributes ownerId and expiresAt. To acquire, do a conditional UpdateItem: 'SET ownerId = :me, expiresAt = :now+ttl IF attribute_not_exists(ownerId) OR expiresAt < :now'.

If the condition succeeds, you have the lock until expiresAt. To release, delete the item conditionally on ownerId = :me. To renew (heartbeat), update expiresAt conditionally.

AWS publishes an official Java client library (DynamoDB Lock Client) implementing this pattern with fencing tokens. Pitfalls: clock skew between clients, always trust the server's `aws:currentTime` (via DynamoDB's built-in support for `:now` in expressions or use a server-generated TTL). Process-pause/network-partition can lead to the lock holder thinking it still holds the lock while a new owner has taken it, use fencing tokens (a monotonic counter) on every write the lock protects to detect this.

For high-frequency leader election (every few seconds), DynamoDB isn't the right tool, use ZooKeeper, etcd, or Consul. For occasional locks (every few minutes), it's fine and avoids running extra infrastructure. Practical notes for the follow-up questions.

Keep the lease short, tens of seconds, and renew it from a background heartbeat: a long lease turns any crash into a long outage, while a short lease with no renewal kills long jobs mid-flight. Do not use DynamoDB TTL to expire the lock, because its sweeper runs on a scale of hours; store expiresAt yourself and compare it inside the ConditionExpression so expiry is evaluated at acquire time. Every acquire, renew and release is a conditional write against a single key, so a contended lock is exactly the hot-item problem, and you should expect throttling and retries with jitter before you expect correctness bugs.

// Acquire: succeeds only if unowned or the previous lease expired.
const now = Date.now();
try {
  await ddb.update({
    TableName: "Locks",
    Key: { lockName: "nightly-settlement" },
    UpdateExpression: "SET ownerId = :me, expiresAt = :exp ADD fencingToken :one",
    ConditionExpression:
      "attribute_not_exists(ownerId) OR expiresAt < :now OR ownerId = :me",
    ExpressionAttributeValues: {
      ":me": processId,
      ":now": now,
      ":exp": now + 30_000,
      ":one": 1,
    },
    ReturnValues: "ALL_NEW",
  }).promise();
} catch (err) {
  if (err.name === "ConditionalCheckFailedException") return; // someone else holds it
  throw err;
}

// Release: only the owner may drop it.
await ddb.delete({
  TableName: "Locks",
  Key: { lockName: "nightly-settlement" },
  ConditionExpression: "ownerId = :me",
  ExpressionAttributeValues: { ":me": processId },
}).promise();
Q37

Walk through how you'd design a high-throughput event-tracking system on DynamoDB.

AdvancedArchitecture

Answer

Event tracking at scale (think: payment events at Razorpay, ride telemetry at Lyft) means write-heavy traffic, time-series access patterns, and often analytics queries on top. The DynamoDB-native shape: **partition key with high cardinality**, `tenantId#deviceId` rather than just `deviceId`, to spread writes across thousands of partitions. **Sort key with time**, `eventTimestamp#eventId` to support range queries like 'events for device X between yesterday and today'. **Write-shard** if cardinality is still too low, append `#0` through `#N` to the PK and randomly pick on write, fan-out on read. **TTL** on raw events (30-90 days), keep the hot data in DynamoDB, archive everything else to S3 via a Stream → Lambda → Firehose pipeline. **Aggregations**: don't run aggregations in DynamoDB, pipe Streams to a Lambda that writes pre-aggregated rollups (hourly, daily) to a separate Aggregations table, or send to Kinesis/Firehose for ad-hoc analytics in S3/Athena. **Capacity**: on-demand for unpredictable launches and growth phases, switch to provisioned + auto-scaling once traffic stabilises (saves 60-70%). **Cost guardrails**: alarm on consumed capacity, contributor insights enabled to catch hot keys before they bite, point-in-time recovery for accidental wipes. This is the pattern Lyft documented in its 2020 re:Invent talk, and what most Indian fintechs use today.

Key Points

  • High-cardinality PK + time-based SK
  • Write-sharding for sub-cardinality keys
  • TTL + Streams + S3 for retention beyond hot window
  • Pre-aggregate via Streams → rollup table or Kinesis → S3/Athena
  • On-demand → provisioned once traffic is steady
Q38

A table returns ThrottledRequests while ConsumedWriteCapacityUnits sits below the provisioned line. How do you debug it?

AdvancedDebugging

Answer

Start from the fact that table-level metrics are averages across partitions, so 'spare capacity' at the table level tells you nothing about any individual partition. Then work a fixed list. First, separate the dimensions: ThrottledRequests counts requests where at least one item was throttled, while WriteThrottleEvents and ReadThrottleEvents count item-level events, and both can be filtered by GlobalSecondaryIndexName.

If the index is the one that is pinned, you have GSI back-pressure and the base table is an innocent bystander. Second, enable CloudWatch Contributor Insights on the table and on the index; it ranks the most-accessed and most-throttled partition keys per minute, which usually identifies one tenant, one device or one date-bucketed key immediately. Third, look at the shape of traffic rather than the average: a one-minute datapoint hides a 200 ms burst, so a cron job firing 5,000 writes in a tight loop throttles against 1,000 provisioned WCU no matter how flat the graph looks.

Fourth, check recent changes: a table switched to on-demand, or one that has never exceeded 2,000 WCU, absorbs only about twice its previous peak until partitions warm up. Fixes follow the cause: shard the key, add jitter to the batch job, register auto-scaling on the index, raise the minimum, or move to on-demand with a MaxWriteRequestUnits ceiling. Finally check the client, because the AWS SDKs retry throttled calls with exponential backoff by default and adaptive retry mode slows the caller down, but a client configured with maxAttempts 1 converts a transient throttle into a user-facing 500.

# Is it the table or an index? Filter the metric by index dimension.
aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB --metric-name ThrottledRequests \
  --dimensions Name=TableName,Value=App Name=GlobalSecondaryIndexName,Value=GSI1 \
  --start-time 2026-05-12T03:00:00Z --end-time 2026-05-12T04:00:00Z \
  --period 60 --statistics Sum

# Which partition keys are hot?
aws dynamodb update-contributor-insights --table-name App \
  --index-name GSI1 --contributor-insights-action ENABLE

// Make the client part of the solution, not the problem (SDK v3).
const client = new DynamoDBClient({
  region: "ap-south-1",
  maxAttempts: 5,
  retryMode: "adaptive", // client-side rate limiting on throttles
});

Key Points

  • Table metrics are averages; partitions throttle individually
  • Filter ThrottledRequests by GlobalSecondaryIndexName first
  • Contributor Insights names the hot key in one screenshot
  • One-minute averages hide sub-second bursts from cron jobs
Q39

DynamoDB Streams delivers at least once. How do you make the side effects happen once?

AdvancedStreams

Answer

You cannot make delivery exactly-once, so you make processing idempotent and make the visible effect happen at most once. Three layers. First, a deterministic idempotency key: eventID on a stream record is stable across redeliveries of that record, so the handler conditionally writes an item with PK = IDEMPOTENCY#<eventID>, attribute_not_exists(PK) and a TTL of a few days.

The second delivery fails the condition with ConditionalCheckFailedException and the handler returns early. Second, make the effect itself idempotent wherever the downstream allows it: SES and every serious payment API accept an idempotency token, an OpenSearch index call with an explicit document ID is a natural upsert, and a DynamoDB write with a ConditionExpression is safe by construction. Third, order the steps so the risky part is recoverable: claim the marker, perform the effect, then stamp it done, and on restart treat a claimed-but-not-done marker as unknown, which is where you either re-check the downstream or knowingly accept a duplicate.

Failure modes to name out loud: a Lambda that times out after sending the email but before writing the marker sends twice, which is why the marker is written first for anything expensive; a batch failure without BisectBatchOnFunctionError redelivers the entire batch, so per-record idempotency matters more than per-batch; and MODIFY records fire for your own writes, so a consumer that writes back into the same table must filter on an attribute or a separate table or it will loop forever. The transactional-outbox variant writes the intent in the same TransactWriteItems as the business change, closing the window where the write lands and the intent is lost.

export const handler = async (event) => {
  for (const record of event.Records) {
    const claimed = await claim(record.eventID);
    if (!claimed) continue; // already processed by an earlier delivery

    await sendOrderConfirmationEmail(unmarshall(record.dynamodb.NewImage));
  }
};

async function claim(eventID) {
  try {
    await ddb.put({
      TableName: "App",
      Item: {
        PK: `IDEMPOTENCY#${eventID}`,
        SK: "MARKER",
        expiresAt: Math.floor(Date.now() / 1000) + 7 * 24 * 3600,
      },
      ConditionExpression: "attribute_not_exists(PK)",
    }).promise();
    return true;
  } catch (err) {
    if (err.name === "ConditionalCheckFailedException") return false;
    throw err; // real failure: let the batch retry
  }
}
Q40

Which DynamoDB changes since 2024 actually change how you design or price a table?

AdvancedRecent Changes

Answer

Five, and this question is how interviewers separate people who have shipped recently from people quoting a 2019 blog post. First, pricing: on-demand throughput dropped roughly 50% and Global Tables replicated writes roughly 67% in November 2024, which moved the default recommendation for a new table from 'provisioned with auto-scaling' to 'on-demand until the bill argues otherwise'. Second, guardrails: on-demand tables now accept MaxReadRequestUnits and MaxWriteRequestUnits, so a runaway retry loop throttles instead of producing a five-figure invoice, and warm throughput lets you declare the peak a table must absorb before a launch rather than running a synthetic traffic ramp.

Third, multi-Region strong consistency for global tables, previewed at re:Invent 2024 and generally available during 2025, which makes a multi-region ledger possible without an external coordinator, at the cost of cross-region write latency and a restricted set of region groups. Fourth, zero-ETL integrations to OpenSearch Service and to Redshift, which delete an entire category of hand-rolled Streams-to-search Lambda that used to be a standard interview answer, and incremental Export to S3, which makes a warehouse feed cheap enough that 'just Scan it nightly' is no longer defensible. Fifth, tooling: AWS SDK for JavaScript v2 reached end of support in September 2025, so anything still calling ddb.put({...}).promise() is running on an unsupported client and should move to @aws-sdk/client-dynamodb with DynamoDBDocumentClient. Worth a sentence too: attribute-based access control means IAM policies can gate on tags instead of enumerating table ARNs.

# Guardrail an on-demand table (2024): throttle instead of surprise billing
aws dynamodb update-table --table-name App \
  --billing-mode PAY_PER_REQUEST \
  --on-demand-throughput MaxReadRequestUnits=40000,MaxWriteRequestUnits=20000

# Pre-warm before a launch instead of running a synthetic ramp
aws dynamodb update-table --table-name App \
  --warm-throughput ReadUnitsPerSecond=30000,WriteUnitsPerSecond=15000

// SDK v2 is end-of-support. This is the v3 shape.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";

const doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "ap-south-1" }));

await doc.send(new PutCommand({
  TableName: "App",
  Item: { PK: "USER#42", SK: "PROFILE", name: "Asha" },
  ConditionExpression: "attribute_not_exists(PK)",
}));

Key Points

  • On-demand ~50% cheaper and global-table writes ~67% cheaper (Nov 2024)
  • MaxRead/WriteRequestUnits and warm throughput are the new guardrails
  • Multi-Region strong consistency for global tables (GA 2025)
  • Zero-ETL to OpenSearch/Redshift replaces hand-rolled Stream consumers
  • AWS SDK for JavaScript v2 is end-of-support since September 2025

Companies Hiring DynamoDB

Amazon
Razorpay
CRED
Zerodha
Postman
Swiggy
Zomato
Lyft

Salary Insights

Average in India
₹8-26 LPA

Frequently Asked Questions

Is DynamoDB worth learning in 2026 if I already know MongoDB?

Yes, if you work or want to work on AWS. They overlap on document modelling but the mental model is different, DynamoDB forces you to design access patterns up front, which is uncomfortable coming from MongoDB but is a transferable skill (it's basically applied data-modelling discipline). Many serverless-first Indian startups specifically ask for DynamoDB experience, and it commands a small premium over generic NoSQL on the salary axis.

How much does a DynamoDB-experienced engineer earn in India?

₹8-26 LPA in 2026 for backend engineers with DynamoDB as a meaningful part of their stack. Companies hiring: Amazon (obviously), Razorpay, CRED, Zerodha's high-throughput teams, Postman, Lyft India, and most serverless-first early-stage startups. Specialised areas (Lambda + DynamoDB at scale, single-table design experts) pay at the upper end.

Should I always use single-table design?

Not always. Single-table design pays off when (a) you have multiple related entities that you fetch together in the same access patterns, and (b) you can invest the upfront design time. For very small apps, isolated microservices owning one entity each, or quick prototypes, multi-table is fine and easier to reason about. Many real production systems are pragmatic, a 'mostly single-table' design with a few specialised tables for things that don't fit (analytics rollups, lookup tables).

How does DynamoDB compare to Cassandra or ScyllaDB?

All three are partitioned NoSQL stores with similar access patterns (key-based, secondary indexes, no joins). DynamoDB wins on operations, zero ops, autoscaling, fully managed. Cassandra and ScyllaDB win on flexibility, they run anywhere, no vendor lock-in, and Scylla in particular often outperforms DynamoDB on per-node throughput. Pick DynamoDB when you're on AWS and want to ship fast. Pick Cassandra/Scylla when you need multi-cloud or have a team that wants to own the database operationally.

When should I NOT use DynamoDB?

When you need rich ad-hoc queries (use Postgres or MongoDB), when your access patterns will change unpredictably and you can't refactor easily (use a relational DB), when you need complex aggregations on every request (use OLAP, Redshift, BigQuery, ClickHouse), when items routinely exceed 400 KB (use S3 + DynamoDB as a metadata index), or when you're not on AWS and don't want to be (DynamoDB is AWS-only, there's no self-hosted version beyond DynamoDB Local for dev).

What should I build to prove DynamoDB skill in an interview?

One small app with a written access-patterns table checked into the repo, not five tutorials. Pick something with real relationships (a ticket tracker, a URL shortener with per-day analytics, an order system) and show the artefacts an interviewer can grade: the list of access patterns, the PK/SK and GSI design that satisfies each one in a single Query, a sparse index used for a work queue, TTL on something expiring, a Streams consumer with idempotency, and a test suite running against DynamoDB Local in Docker. Being able to explain why you chose two GSIs instead of four counts for far more than the number of features.

Do I need AWS SDK v3, or is v2 still fine to learn on?

Learn v3. AWS SDK for JavaScript v2 reached end of support in September 2025, so new code should use @aws-sdk/client-dynamodb with DynamoDBDocumentClient from @aws-sdk/lib-dynamodb, which marshalls plain objects for you and gives you the modular bundle size Lambda cold starts care about. You will still meet v2 code in older repositories (the .promise() style), so being able to read it is useful, and the request shapes are close enough that translating is mechanical. For Python, boto3 with resource.Table() versus client is the equivalent choice, and the same rule applies: know both, write the modern one.

Introduction

DynamoDB is AWS's fully-managed NoSQL database, designed for predictable single-digit-millisecond latency at any scale. It powers Amazon.com itself, and by 2026 has become the default datastore for serverless Indian startups, anywhere you see Lambda + API Gateway, DynamoDB is usually nearby.

Interviews for DynamoDB-heavy roles in India today focus on three things: single-table design (the philosophy that separates a DynamoDB expert from a beginner), partition-key strategy (avoiding hot partitions), and capacity mode tradeoffs (on-demand vs provisioned with auto-scaling). Razorpay, CRED, and many serverless-first startups specifically test single-table modelling on the whiteboard.

This guide covers the 40 most-asked DynamoDB questions in 2026, grouped by difficulty: 14 basic, 18 intermediate, 8 advanced. Each answer includes the underlying concept, the production failure mode it maps to, and a code example where it adds clarity.

Ready to practice DynamoDB interviews?

Don't just read, practice these DynamoDB questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview