PTEN

DynamoDB filtered export: export one tenant without paying for the whole table

DynamoDB filtered export to S3 in a multi-tenant SaaS: only a key condition on the partition key cuts the cost. Math, TypeScript code and limits.

Author
Will Peixoto
Published
05 Nov 2026
Reading
15 min
Language
EN

🇧🇷 Leia em português: DynamoDB filtered export: exporte um tenant sem pagar a tabela inteira

The customer email lands: “we need a copy of all our data by Friday”. You run a multi-tenant SaaS on a single DynamoDB table, 400 GB with everybody's data mixed together, and the follow-up question is an annoying one: how do I pull just this customer out, without hurting production and without paying to export the whole table to S3?

Until September, the answer was ugly. Since October 1, 2026 we have DynamoDB filtered export to S3, and it solves this really well, with one condition: how you designed your partition key. That's what decides whether this tenant's export costs cents or costs the whole table. Let's see why, with the math on the table and the code in hand.

What DynamoDB filtered export to S3 is

DynamoDB export to S3 already existed in two flavors: full (the whole table at a point in time) and incremental (what changed within a window). The problem is that both took the whole table. To get a single customer, you either ran Query against the live table (paginating, burning RCUs, current state only), or exported everything and filtered in Athena, paying per GB for the entire table.

Filtered export adds a new parameter to the same API, ExportTableToPointInTime: FilterSpecification. Inside it you send up to three things, using the same syntax you already know from Query and Scan:

  • KeyConditionExpression: a single partition key value, with equality, plus an optional condition on the sort key.
  • FilterExpression: a condition on any attribute (=, <>, OR, contains, IN, begins_with, BETWEEN, attribute_exists, size).
  • ProjectionExpression: which attributes go into the output files.

It works for both full and incremental exports. One detail I really like: the export reads from the PITR (point-in-time recovery) backup, and the live table stays out of the story. So it doesn't consume read capacity and doesn't touch production performance. AWS lists granular recovery, moving subsets between accounts, and compliant analytics as use cases.

Region-wise, it's available in all AWS Regions except GovCloud. Since I'm writing from Brazil, the case I care about is sa-east-1, and it's included: data that has to stay in São Paulo can stay there for all of this to work. The same goes for whatever region your data residency rules pin you to.

DynamoDB filtered export flow: multi-tenant table with PITR, export filtered by the tenant partition key, files in Amazon S3, consumed via Athena, customer delivery or import-table
Fig. 01

The rule that sets the cost: key condition cuts the bill, filter doesn't

The doc is blunt about it: because the key condition limits the export to a single partition key value, it reduces the amount of data DynamoDB reads. A filter expression, on the other hand, is applied after the read, so it doesn't reduce the data processed. And the pricing page closes the loop: when the export uses a key condition to select a partition key value, you pay only for the matched data, with a 10 MB minimum per export. No extra charge for the filter itself.

Picture a moving company handling one resident of an apartment building. If you give them the apartment number (the partition key), they go up, pack that apartment and charge for it. If all you say is “take the Smith family's stuff” (a filter on some attribute), they load the entire building onto the truck, sort out the Smiths' boxes on the sidewalk and charge for a full truck. The S3 output is the same. The bill isn't.

Mapping that to your SaaS:

  • Table with PK = TENANT#<id>: exporting one tenant bills that tenant's partition.
  • Table with PK = ORDER#<id> and a loose tenantId attribute on the item: the “per-tenant export” becomes a filter expression and bills the whole table.
  • tenantId as the partition key of a GSI: doesn't save you. Filtered export doesn't support secondary indexes at launch.

Let's run the numbers (illustrative figures)

Ground rules before the numbers: the per-GB price comes from the official example on the on-demand pricing page (29 GB for $2.90, so $0.10 per GB), which is us-east-1. I couldn't confirm the sa-east-1 rate, and AWS pricing varies by region, so check the pricing page for your region before this goes into a budget. Table and tenant sizes are hypothetical, just to illustrate.

Scenario: 400 GB table, customer “acme” with 1.5 GB, customer “tiny-bakery” with 2 MB.

ScenarioMathExport cost
Full table export, filtering afterwards in Athena400 GB × $0.10$40.00
Filtered export with only a FilterExpression on tenantId400 GB × $0.10$40.00
Filtered export with a KeyConditionExpression on PK (acme)1.5 GB × $0.10$0.15
Filtered export with key condition (tiny-bakery, 2 MB)10 MB minimum billedabout $0.001

Now multiply. If your support team runs 50 tenant exports a month, the model with tenantId as a regular attribute costs 50 × $40 = $2,000 a month, in exports alone. With the tenant in the partition key, the bill tracks the size of each customer instead.

There's one more line on the invoice you can't forget: PITR. It's a prerequisite and it's billed on table size (data plus LSIs). In the official example, $0.20 per GB-month. For our 400 GB, that's $80 a month if you don't run PITR today. And shortening retention (from 35 days down to 1) doesn't reduce that cost, per the pricing page itself. If you already run PITR for resilience (and in production you should), that line is already paid. S3, PUT requests, Athena and data transfer come on top.

This kind of math fits right into the first law of Werner Vogels' The Frugal Architect: make cost a non-functional requirement. In DynamoDB, that means thinking about per-tenant export when you pick the key, because changing the key later is a migration. I covered the same idea from a different angle in the post on the price of high availability.

Partition key design in a multi-tenant SaaS

The AWS Well-Architected SaaS Lens describes three isolation models: silo (dedicated resources per tenant), pool (everyone sharing) and bridge (a mix). In DynamoDB, silo usually means a table per tenant, and pool usually means a single table with the tenant in the key.

Before filtered export, silo had a practical edge on portability: exporting the customer's table was exporting the customer. Filtered export closes most of that gap for pool, as long as the tenant is the partition key.

The design that plays well with filtered export is classic single-table design:

PKSKAttributes
TENANT#acmeUSER#123name, email, role
TENANT#acmeORDER#2026-10-01#987status, amount
TENANT#acmeINVOICE#2026-09total, due date

All of the tenant's data lives under the same partition key, so a key condition PK = TENANT#acme grabs everything in one go.

Now, it's not all sunshine and rainbows. A huge tenant on a single partition becomes a hot partition, and the well-known fix is write sharding: spreading the tenant across TENANT#acme#0 through TENANT#acme#9. But the key condition accepts a single partition key value per export. A tenant with 10 shards means 10 exports, each with its own 10 MB minimum and its own output files. If sharding solves your hot partition, keep it. Just make sure your export code knows the tenant's shard map. Same logic for N tenants: N exports.

And what if your current model has PK = ORDER#id? Then there's no way around it: filtered export still works through a filter expression, with the right result and the whole-table bill. Migrating the key is a big decision, one I'd only make if per-tenant export becomes routine.

In practice: exporting one tenant in TypeScript

Step one, PITR on. Without it the call fails with PointInTimeRecoveryUnavailableException. Command straight from the official doc:

bash
aws dynamodb update-continuous-backups \
  --table-name SaasTable \
  --point-in-time-recovery-specification PointInTimeRecoveryEnabled=True

Now the export. The field names below match the SDK for JavaScript v3 example in Requesting a table export. I only swapped the values for the tenant scenario and added ClientToken and KMS, which come from the API reference:

ts
import {
  DynamoDBClient,
  ExportTableToPointInTimeCommand,
  DescribeExportCommand,
} from "@aws-sdk/client-dynamodb";

const client = new DynamoDBClient({ region: "sa-east-1" });

export async function exportTenant(tenantId: string, at: Date) {
  const { ExportDescription } = await client.send(
    new ExportTableToPointInTimeCommand({
      TableArn: "arn:aws:dynamodb:sa-east-1:111122223333:table/SaasTable",
      ExportTime: at, // must fall inside the PITR window
      S3Bucket: "saas-tenant-exports",
      S3Prefix: `tenants/${tenantId}/`,
      ExportFormat: "DYNAMODB_JSON",
      S3SseAlgorithm: "KMS",
      S3SseKmsKeyId: "arn:aws:kms:sa-east-1:111122223333:key/EXAMPLE",
      // idempotent for 8 hours: a retry with the same token won't duplicate the export
      ClientToken: `export-${tenantId}-${at.getTime()}`,
      FilterSpecification: {
        KeyConditionExpression: "#pk = :pk",
        ExpressionAttributeNames: { "#pk": "PK" },
        ExpressionAttributeValues: { ":pk": { S: `TENANT#${tenantId}` } },
      },
    })
  );

  return ExportDescription?.ExportArn;
}

export async function checkExport(exportArn: string) {
  const { ExportDescription } = await client.send(
    new DescribeExportCommand({ ExportArn: exportArn })
  );
  // BilledSizeBytes shows what this export actually put on the bill
  return {
    status: ExportDescription?.ExportStatus,
    items: ExportDescription?.ItemCount,
    billedBytes: ExportDescription?.BilledSizeBytes,
  };
}

Three things to handle before this goes to production. The export is asynchronous and has no completion-time SLA, so don't build a flow that depends on it finishing in X minutes. Orchestrate it with Step Functions: start, wait, poll DescribeExport in a loop. ClientToken is your Idempotency Key: if the Lambda retries, the export isn't duplicated. And BilledSizeBytes is the best way to prove to your team that the key is doing its job.

To avoid a ValidationException: with a key condition, the key attribute can't show up again in the filter expression, and each expression is capped at 4 KB and 300 operators.

If the use case is incident recovery (someone ran a bad script against a tenant between 2:00 and 2:45 PM), a filtered incremental export with NEW_AND_OLD_IMAGES gives you the before and after of every item changed in that window, for that tenant only. The incremental window goes from 15 minutes to 24 hours. The official launch blog post has that full example in the CLI.

What to do once the files land in S3

In a full export, files land in <prefix>/AWSDynamoDB/<ExportId>/, with manifest-summary.json, manifest-files.json and a data/ folder of .json.gz files (incremental uses a different layout, with NewImage and OldImage). In the DynamoDB JSON format of a full export, each line is an item wrapped in Item, with marshalled types ({"S": "..."}, {"N": "..."}). The other format option is Amazon Ion.

To query it, Athena reads the compressed files directly. Adapting the DDL from the original export launch post:

sql
CREATE EXTERNAL TABLE IF NOT EXISTS tenant_acme_export (
  Item struct <PK:struct<S:string>,
               SK:struct<S:string>,
               status:struct<S:string>>
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
LOCATION 's3://saas-tenant-exports/tenants/acme/AWSDynamoDB/{EXPORT_ID}/data/';

SELECT Item.SK.S AS sk, Item.status.S AS status
FROM tenant_acme_export
WHERE Item.SK.S LIKE 'ORDER#%';

From here, the three most common destinations:

  • Portability and support: convert the marshalled JSON to CSV or plain JSON and deliver it through a short-lived presigned URL.
  • Per-customer analytics: Athena over that slice only, with no exposure of other tenants.
  • Restore or migration: import-table brings it back, but it always creates a new table (GSIs go in --table-creation-parameters). To put items back into the original table, the write-back is your code, and writes that arrived between the export and the cutover need to be drained or replayed.

And if what you want is a continuous stream of changes into a data lake, that's a different conversation: DynamoDB Streams and the streaming tools. I compared those options in the post on data streaming on AWS with Kinesis, Firehose, Flink and MSK.

Privacy laws: what the export helps with and what it creates

The usual disclaimer: I'm an architect, and nothing here is legal advice. What your company has to deliver, and how, is for your legal team or DPO to decide.

With that said, here's my context. I work in Brazil, under LGPD (Brazil's data protection law, similar to GDPR). Article 18 of LGPD lists data subject rights that include access and data portability. If you're in Europe, the parallel is GDPR's right of access (Article 15) and right to data portability (Article 20). Either way, it's common for a B2B customer to ask for an extract of their data because of a request like that, or because they're leaving the platform. Filtered export makes that extraction cheap and keeps it off production, as long as the key cooperates.

On the flip side, it creates a new copy of personal data in a bucket. And DynamoDB never deletes export output from your bucket. So the engineering part is on you: a lifecycle policy expiring the export prefix, KMS with a tight key policy, bucket access through a dedicated role, CloudTrail on. That maps straight onto the Security pillar of Well-Architected (data protection at rest, least-privilege, traceability). Without lifecycle, the export you ran for a support ticket in 2026 is still sitting there in 2028.

Limitations to keep on your radar

  • PITR is required, and the point in time has to fall inside the retention window (up to 35 days).
  • 10 MB minimum billed per export, one partition key value per export, and no secondary indexes at launch.
  • Quota of up to 300 concurrent exports (or 100 TB in flight), per the service quotas page. To export hundreds of tenants at once, put a queue in front.
  • Same consistency as a full export: eventual across partitions and no notion of transactions. Don't use it as a snapshot of invariants spanning different partitions.
  • Format: DynamoDB JSON or Amazon Ion, compressed. Parquet is a step on your side afterwards (Glue, Athena CTAS).
  • Export metadata expires after 90 days and disappears from ListExports and the console (the S3 files stay). Keep the ExportArn and BilledSizeBytes in your own records if you need an audit trail.
  • Cross-account works, with S3BucketOwner required for a bucket in another account. Handy for delivering the slice into an isolated account.

When to use what

Query on the tableFull export + AthenaFiltered export
Sourcelive table (RCU)PITRPITR
Production impactyesnono
Point in timenowup to 35 daysup to 35 days
CostRCU per item readGB of the whole tableGB of the partition (with key condition)
Outputpaginated to the appS3, everythingS3, just the slice

Query is still fine for a small-volume “download my data” button. Full export is still the path for the company-wide data lake. Filtered export owns the middle case: one tenant, at a point in time, without touching production.

What you take away

  • Filtered export (October 1, 2026) adds FilterSpecification to ExportTableToPointInTime and works for both full and incremental exports, sa-east-1 included.
  • Only KeyConditionExpression reduces the billed volume. A filter expression on a regular attribute delivers the right slice and bills the whole table.
  • In a single-table SaaS, the tenant ID in the partition key is what turns one customer's export into cents. A GSI doesn't help at launch.
  • Add to the math: PITR (billed on table size), the 10 MB minimum per export, and one export per partition key value.
  • After S3: Athena to query, lifecycle and KMS so no tenant copy is left behind, and import-table knowing it creates a new table.

Sources

A question for those of you running SaaS on DynamoDB: is the tenant in your table's partition key today, or is it sitting in a loose attribute waiting for the first export bill? Tell me in the comments. And if your team is designing multi-tenancy right now, send them this post before the key model is locked in. If this post helped, drop a like and share it around. Thanks a lot!

BUILD. SCALE. REPEAT. =D

Will Peixoto

Will Peixoto is a Staff Engineer at Serverless Guru and writes about serverless, AWS architecture and AI agents: what he'd pick, why, and what it costs.

Keep reading

New posts, straight to your inbox

One serverless, architecture or AI agents topic on AWS per issue: what I'd pick, why, and what it costs. No spam.

Opens the subscription page in a new tab.