AWS Builder Center
48,000 bad records, 2 GB that mattered, and the 4 TB DynamoDB table restore I didn't have to run

48,000 bad records, 2 GB that mattered, and the 4 TB DynamoDB table restore I didn't have to run

DynamoDB filtered export lets you pull one partition key, a condition, or a chosen set of attributes straight from the PITR backup to S3, with no table capacity used. I walk through recovering a single corrupted tenant from a multi-tenant table without the 4 TB restore, plus sharing data with PII excluded and relocating a tenant across Regions.

I run a multi-tenant DynamoDB table. One table, one partition key per tenant, several hundred tenants, about 4 TB in total. The day a deployment corrupts one tenant's data is the day I learn how my recovery options really feel, and until recently they all felt wrong.
If I wanted that tenant's items as they existed before the bad write, a point-in-time restore gave me a 4 TB copy of the whole table to recover roughly 2 GB that mattered. A Query against the tenant's partition gave me the data as it is now, which is the corrupted state I am trying to undo. Neither of those is "give me this one tenant, as of this moment, in a file I can work with."
Filtered export is that missing option. It adds a FilterSpecification to the ExportTableToPointInTime request you already know, and it reads from the point-in-time recovery (PITR) backup rather than the table. Because it reads the backup, it uses no table read capacity and does not compete with your production traffic, no matter how large the table is. You get only the items and attributes you asked for, written to S3.

What it actually is

Before this, the unit of export was the whole table. You exported everything and filtered downstream, or you queried the live partition and got only the current state. Filtered export moves the selection into the export request itself, using the same expression syntax you already write for Query and Scan.
The FilterSpecification has five fields, and the two that matter most behave differently from each other:
  • KeyConditionExpression picks one partition key value (equality only, with an optional sort key condition), exactly like a Query. This one restricts the partitions the export reads, so it processes less data and can cost less.
  • FilterExpression applies conditions on any attribute, key or non-key, like a Scan. It runs after the read, so it selects what gets written but does not reduce the data processed.
  • ProjectionExpression names the attributes to write.
  • ExpressionAttributeNames and ExpressionAttributeValues are the substitution tokens, same as always.
The distinction between the key condition and the filter is the thing to internalize: the key condition shrinks what the export reads, the filter only shrinks what it writes. That difference shows up again in the pricing, below. Filtering works on both full exports and incremental exports, and DescribeExport echoes back the FilterSpecification it applied so you can confirm what ran.

Recovering one corrupted tenant

Here is the case I started with. A migration job ran against one tenant with a defect: it recomputed an amount field with the wrong multiplier and flipped active work orders to a completed status. The rollback was quick, but about 48,000 of that tenant's items were already wrong. No other tenant was touched.
I need that tenant's items as they were just before the job's first write, and I need to see exactly what changed. An incremental export over the corruption window does both. It writes only the items that changed between two timestamps, each carrying its state before the window and its state at the end, and I key it to the one tenant:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
aws dynamodb export-table-to-point-in-time \
--table-arn arn:aws:dynamodb:us-east-1:111122223333:table/FieldServiceData \
--export-type INCREMENTAL_EXPORT \
--incremental-export-specification '{
"ExportFromTime": "2026-09-10T14:00:00Z",
"ExportToTime": "2026-09-10T14:45:00Z",
"ExportViewType": "NEW_AND_OLD_IMAGES"
}'
\
--s3-bucket amzn-s3-demo-bucket \
--s3-prefix exports/tenant-4213-recovery/ \
--export-format DYNAMODB_JSON \
--client-token tenant-4213-recovery-20260910 \
--filter-specification '{
"KeyConditionExpression": "#tid = :tid",
"ExpressionAttributeNames": {"#tid": "TenantId"},
"ExpressionAttributeValues": {":tid": {"S": "tenant-4213"}}
}'
The 45-minute window clears the 15-minute minimum for an incremental export. I pass no filter or projection here, because recovery needs whole items. The export runs in the background, and DescribeExport reports when it is done and echoes the filter it used. Each line of the output is one changed item with an OldImage (the state before the window) and a NewImage (the state at the end):
1
2
3
4
5
6
7
8
9
10
11
{
"Keys": {"WorkOrderId": {"S": "WO-0010041"}, "TenantId": {"S": "tenant-4213"}},
"OldImage": {
"Status": {"S": "IN_PROGRESS"}, "AmountDue": {"N": "48250"},
"UpdatedAt": {"S": "2026-09-09T22:17:03Z"}
},
"NewImage": {
"Status": {"S": "COMPLETED"}, "AmountDue": {"N": "4825000"},
"UpdatedAt": {"S": "2026-09-10T14:09:41Z"}
}
}
A record with no OldImage was created inside the window; one with no NewImage was a deletion. Having both images in one file is what makes this useful: the corruption signature is right there. The job set status to completed and changed the amount, so any record where the status flipped to completed and the amount also changed is the job's work, and a record where only one changed is ordinary tenant activity. You can confirm that split with Athena over the export's data folder before you touch production, then write each OldImage back with a conditional PutItem that only fires while the item still carries its in-window timestamp. PutItem replaces the whole item, so if the write-back dies halfway you just run it again and it converges, no bookkeeping.
The payoff: one export of roughly 2 GB instead of a 4 TB restore, and the changed set and the clean copy arrive together.

Sharing a tenant's history, minus the PII

The second pattern is a ProjectionExpression. I agreed to give a partner analytics team one tenant's work order history with customer contact details left out. The projection is an include list, so I name the attributes I want shared and the ones I leave out never reach the bucket:
1
2
3
4
5
6
7
8
9
--filter-specification '{
"KeyConditionExpression": "#tid = :tid",
"ProjectionExpression": "#tid, #wo, #st, #pri, #amt, #ua",
"ExpressionAttributeNames": {
"#tid": "TenantId", "#wo": "WorkOrderId", "#st": "Status",
"#pri": "Priority", "#amt": "AmountDue", "#ua": "UpdatedAt"
},
"ExpressionAttributeValues": {":tid": {"S": "tenant-7788"}}
}'
One thing to be careful about: the projection selects by attribute name, not by content. It does not read values. If a free-text field carries personal information, it passes through unless you leave that field out of the projection. The destination bucket can sit in another account or another Region.

Relocating a tenant to another Region

The third pattern pairs a full filtered export at a point in time with an S3 import. A tenant moves to Europe and wants its data held there, so I export just that tenant to an eu-west-1 bucket with --export-time, then run import-table in that Region to build a new table from the files. The other tenants stay put. Import from S3 creates a brand new table, so if the source has global secondary indexes you declare them in the import's creation parameters. Any writes that land in the source Region between the export time and your cutover are yours to replay or drain before you switch traffic.

Where it stops

Filtered export is narrow by design, and the edges are worth knowing before you lean on it:
  • No secondary indexes at launch. You filter against the base table only, same as full export today.
  • The key condition is equality-only on the partition key. If you need a range or a not-equals on the partition key, you express it in the filter instead, and then it no longer shrinks what the export reads.
  • A non-key filter does not save you money. It trims the output file, but the export still reads the data to apply the filter. Only a KeyConditionExpression on the partition key reduces the data read and therefore the bill (down to a 10 MB per-export minimum).
  • It is not transaction aware. Consistency matches full export: all writes up to the requested time are captured, with eventual consistency across partitions. Do not expect a transactionally consistent cut across items.
  • PITR must be on, and PITR bills at its standard rate whether or not you ever export.
None of these are dealbreakers for the three patterns above. They are the difference between reaching for this tool correctly and being surprised later.

When to reach for it

Reach for filtered export when you want a slice of a table as a file and you do not want to pay in table capacity to get it: recovering or inspecting one tenant or one key, handing a scoped and PII-trimmed dataset to another team or account, or seeding a table in another Region from a point in time. If what you actually need is the live current state of a partition for your application to read, that is still a Query. And if you need every item in the table, a plain full export is simpler than writing a filter that matches everything.
Filtered export is available in all commercial Regions, and it bills at the same per-GB rate as full and incremental export with no premium for the filtering. The getting-started path is the DynamoDB data export to Amazon S3 documentation . If you have a multi-tenant table, the tenant-recovery pattern is the one I would set up and rehearse before you need it.
These are my own views and do not represent my employer.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article