If you have ever owned a nightly job that pushes millions of items into a live DynamoDB table, you know the drill: a Glue job or a container calling BatchWriteItem 25 items at a time, retry logic for unprocessed items, a capacity bump before the window, alarms muted by hand, and a "report" that is mostly log grepping.
On November 5, 2026, AWS made bulk put items for Amazon DynamoDB generally available. It is a managed, asynchronous operation that reads files from Amazon S3 and writes them into an existing table, including one that is serving production traffic, using dedicated capacity that does not touch your table's throughput. This post covers how it works, how to run it from the AWS CLI and from a Kubernetes CronJob on EKS, what it costs, and the gotchas worth knowing before you migrate a pipeline to it.
TL;DR
- Loads data from S3 into an existing table, in the background, on dedicated capacity. Bulk writes do not show up in table consumed-capacity metrics, so your alarms and auto scaling keep seeing real application traffic only.
- Every input line can carry its own condition expression (same grammar as
PutItem): insert-only writes, version-guarded writes, or unconditional ones, mixed in the same file. - Writes a report to S3 with failed items and, optionally, condition-skipped items, including
newImageandoldImage. - Two tiers: Standard ($0.10/GB, up to 100 GB per operation) and Accelerated ($0.20/GB, up to 1 TB), US East (N. Virginia) prices.
- Standard is in all commercial Regions and GovCloud (US); Accelerated is in 23 Regions, including South America (São Paulo).
- No global tables at launch, whole-item writes only, not transactional.

Bulk put items writes through a dedicated capacity pool and drops its report in S3. Image: AWS.
Where it fits
DynamoDB already had two bulk paths, and the new one sits right between them:
| BatchWriteItem | Import from S3 | Bulk put items | |
|---|---|---|---|
| Target table | Existing | New (it creates it) | Existing |
| Who writes | Your code | DynamoDB | DynamoDB |
| Capacity | Your table's | None consumed | Dedicated, isolated |
| Per-item conditions | No | No | Yes, per line |
| Error report | Build your own | CloudWatch Logs | S3, with old/new images |
What it is not: it is not a partial update (use UpdateItem for attribute-level changes), it is not transactional (cancelling leaves already-written items in place), and it does not support global tables yet.
How it works
- Put line-delimited DynamoDB JSON or Amazon Ion files under an S3 prefix, optionally gzip or zstd compressed.
- Call
StartBulkPutItems. It returns immediately with an operation ARN. - DynamoDB reads, validates, and writes in the background. GSIs and LSIs are updated (index replication also runs on isolated capacity), and items show up in DynamoDB Streams and Kinesis Data Streams like any other write, so zero-ETL integrations and stream consumers will see them.
- The operation ends as
COMPLETED,COMPLETED_WITH_ERRORS,FAILED, orCANCELLED.
Expect counters at zero for the first few minutes while the operation provisions, and note that they refresh periodically, not in real time.
Conditions per line are the real feature
{"Item": {"pk": {"S": "SKU#1001"}, "name": {"S": "Gourd mate"}, "stock": {"N": "40"}}} {"Item": {"pk": {"S": "SKU#1002"}, "name": {"S": "Alpaca bombilla"}}, "ConditionExpression": "attribute_not_exists(pk)"} {"Item": {"pk": {"S": "SKU#1003"}, "price": {"N": "8900"}, "version": {"N": "12"}}, "ConditionExpression": "attribute_not_exists(#v) OR #v < :v", "ExpressionAttributeNames": {"#v": "version"}, "ExpressionAttributeValues": {":v": {"N": "12"}}} The classic trap: writing only #v < :v. That condition fails when the item does not exist yet, because there is nothing to compare. Classmethod hit exactly this in their day-one test. Use attribute_not_exists(#v) OR #v < :v, as the docs show.
Condition-skipped rows are not failures; they count as processed and land in the CONDITION_CHECK_FAILURE report. They are, however, billed, even against non-existent items (at the 1 KB minimum).

Operation lifecycle and the retry loop. Diagram: CloudAcademy.ar (Spanish).
Hands-on
You need AWS CLI 2.39.4 or later. Permissions: account-scoped dynamodb:StartBulkOperation, table-scoped dynamodb:BulkPutItems, read on the input bucket, write on the report bucket, and KMS access if you use customer managed keys. The operation runs with the caller's permissions.
aws dynamodb start-bulk-put-items \ --table-name catalog \ --input-s3-configuration '{ "S3Bucket": "my-input-bucket", "S3KeyPrefix": "catalog/2026-11-09/", "InputFormat": "DYNAMODB_JSON", "InputCompressionType": "GZIP" }' \ --output-s3-configuration '{ "S3Bucket": "my-report-bucket", "S3KeyPrefix": "bulk/catalog/", "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"] }' \ --performance-tier STANDARD \ --client-token "catalog-2026-11-09" Always pass --client-token: the same token and parameters within 8 hours return the same operation instead of starting a duplicate, which is exactly what you want when a scheduler retries. --max-write-request-units (minimum 1,024) caps the write rate of this operation only; set it too low and the operation can hit its time limit and fail with Timeout.
aws dynamodb describe-bulk-operation \ --bulk-operation-arn "$ARN" \ --query 'BulkOperationDescription.{status:BulkOperationStatus,processed:ProcessedItemCount,failed:FailedItemCount}' aws dynamodb list-bulk-operations --max-items 25 # 90 days of history aws dynamodb cancel-bulk-operation --bulk-operation-arn "$ARN" Running it from EKS
If your platform already lives on EKS, you don't need Glue or Step Functions for this. A CronJob with the AWS CLI image and EKS Pod Identity is enough, and making COMPLETED_WITH_ERRORS fail the Job surfaces partial loads in your existing Kubernetes alerting:
apiVersion: batch/v1 kind: CronJob metadata: name: catalog-bulk-put namespace: data-jobs spec: schedule: "0 4 * * *" concurrencyPolicy: Forbid jobTemplate: spec: backoffLimit: 1 template: spec: serviceAccountName: catalog-bulk-put # mapped to an IAM role via EKS Pod Identity restartPolicy: Never containers: - name: bulk-put image: amazon/aws-cli:latest # pin a version >= 2.39.4 command: ["/bin/bash", "-c"] args: - | set -euo pipefail BATCH=$(date -u +%F) ARN=$(aws dynamodb start-bulk-put-items \ --table-name catalog \ --input-s3-configuration "{\"S3Bucket\":\"my-input-bucket\",\"S3KeyPrefix\":\"catalog/${BATCH}/\",\"InputFormat\":\"DYNAMODB_JSON\",\"InputCompressionType\":\"GZIP\"}" \ --output-s3-configuration "{\"S3Bucket\":\"my-report-bucket\",\"S3KeyPrefix\":\"bulk/catalog/${BATCH}/\",\"OutputType\":[\"ERROR\",\"CONDITION_CHECK_FAILURE\"]}" \ --performance-tier STANDARD \ --client-token "catalog-${BATCH}" \ --query 'BulkOperationDescription.BulkOperationArn' --output text) while true; do STATUS=$(aws dynamodb describe-bulk-operation --bulk-operation-arn "$ARN" \ --query 'BulkOperationDescription.BulkOperationStatus' --output text) echo "$(date -u +%T) ${STATUS}" case "$STATUS" in IN_PROGRESS|CANCELLING) sleep 60 ;; COMPLETED) exit 0 ;; *) exit 1 ;; esac done Gotchas
- One bulk operation per table at a time, and not while an index is being created.
- Up to 50,000 input objects and 5 GB per object; 5 Standard plus 5 Accelerated operations in flight per account.
- The table must be
ACTIVE; restoring tables are not supported. - Size your stream consumers for normal traffic plus the import, or throttle the import with
--max-write-request-units. - Accelerated does not support tables with vector indexes.
Pricing
Billing is per GB written, with a 1 KB minimum per item, metered separately for the table and each GSI the write propagates to. In us-east-1 that's $0.10/GB on Standard and $0.20/GB on Accelerated. AWS's example: 100 million 1 KB items = 100 GB = $10 (Standard) or $20 (Accelerated). Validation-rejected items are free; failed condition checks are not. Reports are billed at normal S3 rates.
Practical ways to keep it cheap: audit which GSIs project the full item, avoid the 1 KB floor with tiny items where you can, send deltas rather than full snapshots (conditions are a safety net, not a filter), and default to Standard. For Accelerated, AWS publishes estimates of under 10 minutes for 10M items, under an hour for 100M, and 2 to 4 hours for 1B, under ideal conditions; benchmark three to five runs before committing to a window.
Region notes
Standard is available in every commercial Region and GovCloud (US). Accelerated covers 23 Regions, and sa-east-1 is one of them, which is good news for teams in Latin America. Keep input and report buckets in the same Region as the table to avoid cross-Region transfer and data residency questions, and treat the report bucket as sensitive: it contains copies of your items.
My take
The big win is not the per-GB price; it is that AWS decoupled bulk loads from table capacity, both in resources and in metrics. Your alarms go back to measuring real traffic, and the load becomes a first-class object with status, history, CloudTrail audit, and a retryable report. Per-line conditions make imports safe to re-run, which is what you need when a scheduler retries at 4 a.m. What's missing: global tables support and a native completion event (EventBridge would be nice) so you don't have to poll. Until then, a CronJob with describe-bulk-operation does the job.
