🇺🇸 Also available in English: DynamoDB filtered export: export one tenant without paying for the whole table
Chegou o e-mail do cliente: “precisamos de uma cópia de todos os nossos dados até sexta”. Você tem um SaaS multi-tenant numa tabela única do DynamoDB, 400 GB de dados de todo mundo misturados, e a pergunta que vem na sequência é chata: como eu tiro só esse cliente dali, sem derrubar produção e sem pagar o export para o S3 da tabela inteira?
Até setembro, a resposta era feia. Desde 01/10/2026 existe o DynamoDB filtered export to S3, e ele resolve isso muito bem, com uma condição: o desenho da sua partition key. É ela que decide se o export desse tenant custa centavos ou custa a tabela inteira. Bora ver por quê, com a conta aberta e o código na mão.
O que é o DynamoDB filtered export to S3
O export para S3 do DynamoDB já existia, em duas versões: full (a tabela inteira num ponto no tempo) e incremental (o que mudou numa janela). O problema é que as duas levavam a tabela inteira. Para pegar um cliente só, ou você fazia Query na tabela viva (paginando, consumindo RCU, só o estado de agora), ou exportava tudo e filtrava no Athena, pagando o GB da tabela inteira.
O filtered export adiciona um parâmetro novo na mesma API, ExportTableToPointInTime: o FilterSpecification. Dentro dele você manda até três coisas, usando a mesma sintaxe que você já conhece de Query e Scan:
-
KeyConditionExpression: um único valor de partition key, com igualdade, mais uma condição opcional na sort key. -
FilterExpression: condição em qualquer atributo (=, <>,OR,contains,IN,begins_with,BETWEEN,attribute_exists,size). -
ProjectionExpression: quais atributos vão para o arquivo.
Funciona em full e em incremental. E um detalhe que eu gosto muito: o export lê do backup de PITR (point-in-time recovery), e a tabela viva fica fora da história. Ou seja, não consome capacidade de leitura e não encosta na performance de produção. A AWS cita como casos de uso recuperação granular, mover subconjuntos entre contas e analytics com compliance.
E para quem está aqui no Brasil: está disponível em todas as regiões AWS, menos as GovCloud. Inclui sa-east-1. Seu dado não precisa sair de São Paulo para nada disso funcionar.

A regra que decide o custo: key condition reduz, filter não
A doc é direta: como a key condition limita o export a um único valor de partition key, ela reduz o volume de dado que o DynamoDB lê. Já o filter expression é aplicado depois da leitura, então não reduz o dado processado. E a página de pricing fecha a conta: quando o export usa key condition para selecionar um valor de partition key, você paga só pelo dado que casou, com mínimo de 10 MB por export. Sem cobrança extra pelo filtro em si.
Pensa numa transportadora fazendo a mudança de um morador de um prédio. Se você passa o número do apartamento (a partition key), ela sobe, embala aquele apartamento e cobra por ele. Se você só diz “leva as coisas da família Silva” (um filtro num atributo qualquer), ela coloca o prédio inteiro no caminhão, separa as caixas dos Silva na calçada e cobra o caminhão cheio. O resultado no S3 é o mesmo. A fatura, não.
Traduzindo para o seu SaaS:
-
Tabela com
PK = TENANT#<id>: o export de um tenant cobra a partição dele. -
Tabela com
PK = ORDER#<id>e um atributotenantIdsolto no item: o “export por tenant” vira filter expression e cobra a tabela inteira. -
tenantIdcomo partition key de um GSI: não salva. O filtered export não suporta índices secundários no lançamento.
Bora abrir a conta (com números ilustrativos)
Combinado antes dos números: o preço por GB sai do exemplo oficial da página de pricing on-demand (29 GB por US$ 2,90, ou seja, US$ 0,10 por GB), que é de us-east-1. Eu não consegui confirmar a tarifa de sa-east-1, e preço na AWS varia por região, então confira na página de pricing antes de levar para o seu orçamento. Os tamanhos de tabela e de tenant são hipotéticos, só para ilustrar.
Cenário: tabela de 400 GB, cliente “acme” com 1,5 GB, cliente “padaria-do-ze” com 2 MB.
| Cenário | Conta | Custo do export |
|---|---|---|
| Full export da tabela, filtrando depois no Athena | 400 GB × US$ 0,10 | US$ 40,00 |
Filtered export só com FilterExpression em tenantId | 400 GB × US$ 0,10 | US$ 40,00 |
Filtered export com KeyConditionExpression no PK (acme) | 1,5 GB × US$ 0,10 | US$ 0,15 |
| Filtered export com key condition (padaria-do-ze, 2 MB) | mínimo de 10 MB cobrado | cerca de US$ 0,001 |
Agora multiplica. Se o seu suporte faz 50 exports de tenant por mês, o modelo com tenantId como atributo comum custa 50 × US$ 40 = US$ 2.000 no mês, só de export. Com o tenant na partition key, a conta passa a acompanhar o tamanho de cada cliente.
Tem mais uma linha na fatura que você não pode esquecer: o PITR. Ele é pré-requisito e é cobrado pelo tamanho da tabela (dados mais LSIs). No exemplo oficial, US$ 0,20 por GB-mês. Para os nossos 400 GB, seriam US$ 80 por mês se você ainda não liga PITR hoje. E encurtar a retenção (de 35 para 1 dia) não reduz esse custo, segundo a própria página de pricing. Se você já roda PITR por resiliência (e deveria, em produção), essa linha já está paga. S3, PUT requests, Athena e data transfer vêm à parte.
Esse tipo de conta encaixa direto na primeira lei do The Frugal Architect, do Werner Vogels: custo é requisito não funcional. No DynamoDB, isso significa pensar em export por tenant quando você escolhe a chave, porque trocar a chave depois é migração. Falei disso com outro sabor no post sobre custo de resiliência, multi-região ou on-premise.
O desenho da partition key no SaaS multi-tenant
O AWS SaaS Lens do Well-Architected descreve três modelos de isolamento: silo (recurso dedicado por tenant), pool (todo mundo compartilhando) e bridge (uma mistura). No DynamoDB, silo costuma ser tabela por tenant, e pool costuma ser tabela única com o tenant na chave.
Antes do filtered export, silo tinha uma vantagem prática em portabilidade: exportar a tabela do cliente era exportar o cliente. O filtered export encurta muito essa distância para quem está em pool, desde que o tenant seja a partition key.
O desenho que funciona com o filtered export é o clássico de single-table design:
| PK | SK | Atributos |
|---|---|---|
TENANT#acme | USER#123 | nome, e-mail, papel |
TENANT#acme | ORDER#2026-10-01#987 | status, valor |
TENANT#acme | INVOICE#2026-09 | total, vencimento |
Todo dado do tenant mora sob a mesma partition key, então uma key condition PK = TENANT#acme pega tudo de uma vez.
Agora, nem tudo são flores. Tenant gigante numa partição só vira hot partition, e o remédio conhecido é write sharding: espalhar o tenant em TENANT#acme#0 até TENANT#acme#9. Só que a key condition aceita um único valor de partition key por export. Tenant com 10 shards vira 10 exports, cada um com mínimo de 10 MB e com o seu próprio arquivo. Se o sharding resolve a sua hot partition, mantenha. Só garanta que o código de export conheça o mapa de shards do tenant. A mesma lógica vale para N tenants: N exports.
E se o seu modelo atual tem PK = ORDER#id? Aí não tem para onde fugir: o filtered export ainda funciona via filter expression, com o resultado certo e a fatura da tabela inteira. Migrar a chave é decisão grande, que eu só tomaria se exportar por tenant virar rotina.
Na prática: o export de um tenant em TypeScript
Primeiro passo, PITR ligado. Sem ele a chamada falha com PointInTimeRecoveryUnavailableException. Comando da doc oficial:
aws dynamodb update-continuous-backups \
--table-name SaasTable \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=TrueAgora o export. Os nomes de campo abaixo são os mesmos do exemplo em SDK JS v3 da doc Requesting a table export. Eu só troquei os valores para o cenário do tenant e adicionei ClientToken e KMS, que estão na API reference:
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, // precisa estar dentro da janela de PITR
S3Bucket: "saas-tenant-exports",
S3Prefix: `tenants/${tenantId}/`,
ExportFormat: "DYNAMODB_JSON",
S3SseAlgorithm: "KMS",
S3SseKmsKeyId: "arn:aws:kms:sa-east-1:111122223333:key/EXEMPLO",
// idempotente por 8 horas: retry com o mesmo token não duplica o 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 mostra o que entrou na fatura deste export
return {
status: ExportDescription?.ExportStatus,
items: ExportDescription?.ItemCount,
billedBytes: ExportDescription?.BilledSizeBytes,
};
}Três cuidados antes de levar isso para produção. O export é assíncrono e não tem SLA de tempo de conclusão, então nada de fluxo que dependa de terminar em X minutos. Orquestre com Step Functions: dispara, espera, consulta o DescribeExport em loop. O ClientToken é o seu Idempotency Key: se a Lambda der retry, o export não duplica. E o BilledSizeBytes é o melhor jeito de provar para o time que a chave está fazendo o trabalho dela.
Para não tomar ValidationException: com key condition, o atributo de chave não pode reaparecer no filter expression, e cada expressão tem limite de 4 KB e 300 operadores.
Se o caso for recuperação de um incidente (alguém rodou um script errado no tenant das 14h às 14h45), o incremental filtrado com NEW_AND_OLD_IMAGES te entrega o antes e o depois de cada item alterado naquela janela, só daquele tenant. A janela do incremental vai de 15 minutos a 24 horas. O blog oficial de lançamento tem esse exemplo completo em CLI.
O que fazer depois que o arquivo chega no S3
No full export, os arquivos caem em <prefixo>/AWSDynamoDB/<ExportId>/, com manifest-summary.json, manifest-files.json e uma pasta data/ com arquivos .json.gz (o incremental usa outro layout, com NewImage e OldImage). No formato DynamoDB JSON do full export, cada linha é um item embrulhado em Item, com os tipos marshalled ({"S": "..."}, {"N": "..."}). A outra opção de formato é Amazon Ion.
Para consultar, o Athena lê os arquivos comprimidos direto. Adaptando a DDL do post oficial de lançamento do export:
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#%';Daqui saem os três destinos mais comuns:
-
Portabilidade e suporte: converter o JSON marshalled para CSV ou JSON limpo e entregar por URL pré-assinada de expiração curta.
-
Analytics por cliente: Athena só no recorte, sem expor os outros tenants.
-
Restore ou migração: o
import-tabletraz de volta, mas sempre cria uma tabela nova (os GSIs vão em--table-creation-parameters). Para devolver os itens na tabela original, o write-back é código seu, e as escritas que chegaram entre o export e o cutover precisam ser drenadas ou reaplicadas.
E se o que você quer é um fluxo contínuo de mudanças para um data lake, aí a conversa é outra: DynamoDB Streams e as ferramentas de streaming. Comparei essas opções no post de data streaming na AWS com Kinesis, Firehose, Flink e MSK.
LGPD: o que o export ajuda e o que ele cria
Aviso de sempre: sou arquiteto, e nada aqui é aconselhamento jurídico. Quem decide o que sua empresa precisa entregar e como é o jurídico ou o DPO.
Dito isso, a LGPD prevê direitos do titular como acesso e portabilidade dos dados (art. 18), e é comum o cliente B2B pedir a extração dos dados dele por causa de uma solicitação dessas ou porque está saindo da plataforma. O filtered export deixa essa extração barata e sem impacto em produção, desde que a chave ajude.
Do outro lado, ele cria uma cópia nova de dado pessoal num bucket. E o DynamoDB nunca apaga o output do export do seu bucket. Então a parte de engenharia é sua: lifecycle policy expirando o prefixo de export, KMS com policy restrita, acesso ao bucket por role específica, CloudTrail ligado. Isso encosta direto no pilar de Security do Well-Architected (proteção de dados em repouso, least-privilege, rastreabilidade). Sem lifecycle, o export que você fez para um ticket de suporte em 2026 continua lá em 2028.
Limitações para ter no radar
-
PITR obrigatório, e o ponto no tempo tem que estar dentro da janela de retenção (até 35 dias).
-
Mínimo de 10 MB cobrado por export, um valor de partition key por export e nada de índice secundário no lançamento.
-
Quota de até 300 exports concorrentes (ou 100 TB em voo), segundo a página de service quotas. Para exportar centenas de tenants de uma vez, coloque uma fila na frente.
-
Consistência igual à do full export: eventual entre partições e sem noção de transação. Não use como snapshot de invariantes entre partições diferentes.
-
Formato: DynamoDB JSON ou Amazon Ion, comprimido. Para Parquet, é etapa sua depois (Glue, Athena CTAS).
-
Os metadados do export expiram depois de 90 dias e somem do
ListExportse do console (os arquivos no S3 ficam). Guarde oExportArne oBilledSizeBytesno seu próprio registro se precisar de auditoria. -
Cross-account funciona, com
S3BucketOwnerobrigatório para bucket de outra conta. Bom para entregar o recorte numa conta isolada.
Quando usar o quê
| Query na tabela | Full export + Athena | Filtered export | |
|---|---|---|---|
| Fonte | tabela viva (RCU) | PITR | PITR |
| Impacto em produção | sim | não | não |
| Ponto no tempo | agora | até 35 dias | até 35 dias |
| Custo | RCU por item lido | GB da tabela inteira | GB da partição (com key condition) |
| Saída | paginada para o app | S3, tudo | S3, só o recorte |
Query segue boa para o botão “baixar meus dados” com volume pequeno. Full export segue sendo o caminho para o data lake da empresa inteira. O filtered export fica com o caso do meio: um tenant, num ponto no tempo, sem tocar em produção.
O que você leva
-
O filtered export (01/10/2026) adiciona
FilterSpecificationaoExportTableToPointInTimee funciona em full e incremental, inclusive em sa-east-1. -
Só a
KeyConditionExpressionreduz o volume cobrado. Filter expression em atributo comum entrega o recorte certo e cobra a tabela inteira. -
Num SaaS em tabela única,
TenantIdna partition key é o que transforma o export de um cliente em centavos. GSI não ajuda no lançamento. -
Some à conta o PITR (cobrado pelo tamanho da tabela), o mínimo de 10 MB por export e um export por valor de partition key.
-
Depois do S3: Athena para consultar, lifecycle e KMS para não deixar cópia de tenant esquecida, e
import-tablesabendo que ele cria tabela nova.
Fontes
Pergunta para você que roda SaaS em DynamoDB: o tenant está na partition key da sua tabela hoje, ou está num atributo solto esperando a primeira fatura de export? Me conta nos comentários. E se o seu time está desenhando multi-tenant agora, manda esse post para ele antes de fechar o modelo de chave. Se curtiu, manda aquele joinha, comenta e compartilha para fortalecer a comunidade, valeu demais!
BUILD. SCALE. REPEAT. =D