AWS

DynamoDB bulk put items: cargá datos masivos desde S3 en tablas en producción, sin pipelines propios

Bulk put items carga datos desde S3 en tablas de DynamoDB que ya están en producción, con capacidad dedicada, condiciones por línea y reporte de errores. Cómo funciona, cómo usarlo desde EKS, cuánto cuesta y sus límites.

13 min de lectura
Amazon DynamoDB bulk put items: cargá datos desde Amazon S3 en tablas existentes

El 5 de noviembre de 2026 AWS lanzó en disponibilidad general bulk put items para Amazon DynamoDB: una operación administrada que lee archivos desde Amazon S3 y los escribe en una tabla que ya existe, incluso si esa tabla está atendiendo tráfico de producción en ese momento. Corre en segundo plano, con capacidad propia, y no te consume la capacidad de lectura y escritura que usa tu aplicación.

Si alguna vez tuviste que refrescar millones de ítems por la noche, esto te va a sonar. Hasta ahora las opciones eran dos: BatchWriteItem desde tu propio código (con reintentos, throttling y un Glue job o un script que alguien escribió hace años) o el Import from S3 de 2022, que solo sirve para crear una tabla nueva. Bulk put items llena justo el hueco del medio. En este artículo vemos qué hace, cómo funciona por dentro, cómo lanzarlo con la AWS CLI y desde un CronJob en Amazon EKS, cuánto cuesta y dónde están las trampas.

TL;DR

  • Carga datos desde S3 en una tabla existente, de forma asincrónica y con capacidad dedicada: no compite con tu tráfico ni dispara tus alarmas de CloudWatch.
  • Cada línea del archivo puede llevar su propia expresión de condición (la misma gramática que PutItem), así que podés hacer altas que no pisan datos o actualizaciones protegidas por versión.
  • Deja en S3 un reporte con los ítems que fallaron y, si querés, con los que se saltearon por su condición, incluyendo la imagen nueva y la existente.
  • Dos niveles de rendimiento: Standard (USD 0,10 por GB, hasta 100 GB por operación) y Accelerated (USD 0,20 por GB, hasta 1 TB), precios de us-east-1.
  • Standard está en todas las regiones comerciales y GovCloud; Accelerated, en 23 regiones, incluida São Paulo (sa-east-1).
  • No soporta global tables por ahora, escribe ítems completos (no actualiza atributos sueltos) y no es transaccional.
  • Se maneja con cuatro comandos: start-bulk-put-items, describe-bulk-operation, list-bulk-operations y cancel-bulk-operation.

Por qué ahora

Cargar datos masivos en una tabla viva es uno de esos trabajos que todos subestiman hasta que les toca. El patrón típico: un proceso de ML genera recomendaciones cada noche, un equipo de catálogo enriquece atributos, o estás migrando desde otra base y necesitás reescribir cientos de millones de ítems. El camino habitual era un job (Glue, EMR, Step Functions o un script en un contenedor) que lee de S3 y llama a BatchWriteItem de a 25 ítems.

Ese camino tiene tres problemas conocidos:

  1. Compite con tu aplicación. Las escrituras masivas consumen la misma capacidad que el tráfico real. En modo provisionado te obliga a subir capacidad (y acordarte de bajarla); en on-demand te puede hacer pegar contra los límites de la partición y generar throttling justo en el horario de carga.
  2. Ensucia tu observabilidad. Las métricas de capacidad consumida se disparan, las alarmas suenan y el auto scaling reacciona a un pico que no es tráfico de usuarios.
  3. El código de carga es tuyo. Reintentos, backoff, manejo de ítems no procesados, idempotencia, reportes de error: todo eso lo escribís y lo mantenés vos.

Bulk put items saca esas tres cosas de tu lado de la cancha. AWS le pone precio por GB escrito, la ejecuta con capacidad aparte y te devuelve un reporte.

Arquitectura de bulk put items: S3 de entrada, operación de carga, pool de capacidad dedicado en la tabla y reporte en S3
Bulk put items escribe con un pool de capacidad dedicado, separado de la capacidad que usa tu aplicación, y deja el reporte de errores en S3. Imagen: AWS.

Qué es y qué no es

Conviene separar bien bulk put items de lo que ya existía, porque los nombres se parecen:

Comparación entre BatchWriteItem, Import from S3 y bulk put items
Las tres formas de cargar datos masivos en DynamoDB y cuándo conviene cada una. Diagrama: CloudAcademy.ar.
BatchWriteItem Import from S3 Bulk put items
Tabla destino Existente Nueva (la crea) Existente
Quién escribe Tu código DynamoDB DynamoDB
Capacidad La de tu tabla No consume Dedicada, aparte de tu tabla
Condiciones por ítem No No Sí, por línea
Reporte de errores Lo armás vos En CloudWatch Logs En S3, con newImage/oldImage
Precio WRU/WCU normales Por GB importado Por GB escrito (tabla + cada GSI)

Lo que no es:

  • No es un upsert parcial. Escribe ítems completos, como un PutItem. Si querés modificar un atributo y dejar el resto, seguís necesitando UpdateItem.
  • No es transaccional. Si lo cancelás a la mitad, lo que ya se escribió queda escrito. No hay rollback.
  • No es para global tables (todavía). No está soportado en el lanzamiento y además no podés agregar réplicas mientras corre una operación.
  • No reemplaza al Import from S3. Si vas a crear una tabla desde cero, el import clásico sigue siendo la opción natural.

Cómo funciona por dentro

El flujo es bastante simple:

  1. Dejás tus archivos en un prefijo de S3, en DynamoDB JSON (JSON Lines, un registro por línea) o en Amazon Ion, opcionalmente comprimidos con gzip o zstd.
  2. Llamás a StartBulkPutItems indicando tabla, prefijo de entrada, bucket de salida, tipo de reporte y nivel de rendimiento. La llamada vuelve enseguida con el ARN de la operación.
  3. DynamoDB lee, valida y escribe en segundo plano usando un pool de capacidad dedicado. Los GSI y LSI se actualizan como con cualquier escritura (la replicación a índices también corre en capacidad aislada) y los ítems aparecen en DynamoDB Streams y Kinesis Data Streams como escrituras normales.
  4. Al terminar, la operación queda en COMPLETED, COMPLETED_WITH_ERRORS, FAILED o CANCELLED, y el reporte queda en S3.

Un detalle que importa para operar: las escrituras del bulk put no cuentan en las métricas de lectura/escritura ni de capacidad consumida de la tabla. Tus alarmas y tus reglas de auto scaling siguen viendo solo el tráfico de la aplicación. La contracara es que, para monitorear la carga, tenés que mirar el estado de la operación y no las métricas de la tabla.

Otro detalle: los primeros minutos la operación está aprovisionando recursos, así que es normal ver los contadores en cero al principio. Los contadores se actualizan de forma periódica (no en tiempo real) y los bytes procesados se miden sobre el tamaño comprimido.

Flujo de una carga idempotente con bulk put items: archivos en S3, inicio, escritura, estado final y reportes
Ciclo de vida de una operación y cómo usar los reportes para reintentar. Diagrama: CloudAcademy.ar.

Condiciones por línea: la parte que más vale

El formato de entrada es el de PutItem: cada línea tiene un Item y, opcionalmente, ConditionExpression, ExpressionAttributeNames y ExpressionAttributeValues. En un mismo archivo podés mezclar escrituras sin condición, altas que no pisan nada y actualizaciones protegidas por versión:

{"Item": {"pk": {"S": "SKU#1001"}, "nombre": {"S": "Mate de calabaza"}, "stock": {"N": "40"}}}
{"Item": {"pk": {"S": "SKU#1002"}, "nombre": {"S": "Bombilla alpaca"}, "origen": {"S": "import"}}, "ConditionExpression": "attribute_not_exists(pk)"}
{"Item": {"pk": {"S": "SKU#1003"}, "precio": {"N": "8900"}, "version": {"N": "12"}}, "ConditionExpression": "attribute_not_exists(#v) OR #v < :v", "ExpressionAttributeNames": {"#v": "version"}, "ExpressionAttributeValues": {":v": {"N": "12"}}}
  • La primera línea escribe siempre.
  • La segunda solo escribe si el ítem no existe (alta pura, ideal para migraciones donde la tabla nueva ya recibe escrituras de la aplicación).
  • La tercera escribe si el ítem no existe o si la versión guardada es menor que la que trae el archivo.

Ojo con esa tercera condición, porque es la trampa más común. Si escribís solo #v < :v, la condición falla cuando el ítem no existe, porque no hay atributo con el que comparar. Classmethod lo probó el mismo día del lanzamiento: su fila versionada se salteó en la primera corrida porque la clave todavía no estaba en la tabla. La forma correcta, que es la que muestra la documentación, es attribute_not_exists(#v) OR #v < :v.

Las filas salteadas por su condición no cuentan como falla: suman en ProcessedItemCount y aparecen en el reporte CONDITION_CHECK_FAILURE si lo pediste. Y algo que conviene saber antes de diseñar: las verificaciones de condición fallidas se facturan, incluso contra ítems inexistentes (con el mínimo de 1 KB).

Standard o Accelerated

Standard Accelerated
Para qué Cargas sin apuro: enriquecimiento, backfills Cargas con fecha límite: migraciones, recuperación, ventanas fijas
Tamaño máximo por operación 100 GB 1 TB
Precio (us-east-1) USD 0,10 por GB USD 0,20 por GB
Regiones Todas las comerciales y GovCloud (US) 23 regiones, incluida sa-east-1
Tablas con índice vectorial Soportado No disponible
Operaciones en curso por cuenta 5 5 (se cuentan aparte)

AWS publica tiempos estimados para Accelerated con ítems de 1 KB, claves de alta cardinalidad bien distribuidas, sin replicación a GSI y la entrada repartida en varios objetos de tamaño parecido: 10 millones de ítems en menos de 10 minutos, 100 millones en menos de una hora y mil millones en 2 a 4 horas. Para Standard no hay cifras; depende de la cantidad y tamaño de ítems, de los índices y de la capacidad disponible del sistema. AWS recomienda Standard para la mayoría de las cargas y para todo lo que pese menos de 1 GB.

También avisan que las primeras corridas pueden ir más rápido de lo que la tabla escala, así que puede hacer falta un par de ejecuciones para llegar al rendimiento óptimo. Si vas a calendarizar algo con Accelerated, medilo en tres a cinco corridas antes de prometer una ventana.

Manos a la obra

Vamos con un caso típico: un catálogo que se refresca todas las noches desde un proceso que deja archivos en S3. Necesitás AWS CLI 2.39.4 o superior (es la versión que trajo los comandos nuevos).

1. Permisos

La operación corre con los permisos de quien la invoca. Hacen falta una acción a nivel de cuenta, una a nivel de tabla, lectura en el bucket de entrada, escritura en el de reportes y acceso a las claves KMS si usás cifrado con clave propia:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "IniciarOperacionBulk",
      "Effect": "Allow",
      "Action": "dynamodb:StartBulkOperation",
      "Resource": "*"
    },
    {
      "Sid": "EscribirEnLaTabla",
      "Effect": "Allow",
      "Action": "dynamodb:BulkPutItems",
      "Resource": "arn:aws:dynamodb:us-east-1:111122223333:table/catalogo"
    },
    {
      "Sid": "LeerEntrada",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": ["arn:aws:s3:::mi-bucket-entrada", "arn:aws:s3:::mi-bucket-entrada/catalogo/*"]
    },
    {
      "Sid": "EscribirReporte",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::mi-bucket-reportes/bulk/*"
    }
  ]
}

StartBulkOperation es una acción a nivel de cuenta, por eso el Resource: "*". Para consultar, listar o cancelar operaciones vas a necesitar además los permisos de esas acciones; revisá los nombres exactos en la referencia de autorización de DynamoDB antes de cerrar la política.

2. Lanzar la carga

aws dynamodb start-bulk-put-items \
  --table-name catalogo \
  --input-s3-configuration '{
    "S3Bucket": "mi-bucket-entrada",
    "S3KeyPrefix": "catalogo/2026-11-09/",
    "InputFormat": "DYNAMODB_JSON",
    "InputCompressionType": "GZIP"
  }' \
  --output-s3-configuration '{
    "S3Bucket": "mi-bucket-reportes",
    "S3KeyPrefix": "bulk/catalogo/",
    "OutputType": ["ERROR", "CONDITION_CHECK_FAILURE"]
  }' \
  --performance-tier STANDARD \
  --client-token "catalogo-2026-11-09"

Dos parámetros que conviene usar siempre:

  • --client-token: si repetís la misma llamada con el mismo token y los mismos parámetros dentro de las 8 horas, te devuelve la misma operación en vez de lanzar otra. Si cambiás parámetros con el mismo token, falla con IdempotentParameterMismatchException. Usar la fecha del lote como token es una forma simple de evitar cargas duplicadas cuando un scheduler reintenta.
  • --max-write-request-units: pone un techo a la velocidad de escritura de la operación (mínimo 1.024). No cambia la configuración de tu tabla. Sirve sobre todo para no ahogar a los consumidores de Streams. Ojo: si lo ponés muy bajo, la operación puede superar su tiempo máximo y terminar en FAILED con Timeout.

3. Seguir el estado

ARN="arn:aws:dynamodb:us-east-1:111122223333:bulk-operation/01759414677739-041d6d92"

aws dynamodb describe-bulk-operation \
  --bulk-operation-arn "$ARN" \
  --query 'BulkOperationDescription.{estado:BulkOperationStatus,procesados:ProcessedItemCount,fallidos:FailedItemCount,bytes:ProcessedInputBytes,total:TotalInputBytes}'

# Historial de los últimos 90 días
aws dynamodb list-bulk-operations --max-items 25

# Cortar una operación (lo escrito queda escrito)
aws dynamodb cancel-bulk-operation --bulk-operation-arn "$ARN"

Cada llamada a estas APIs queda registrada en AWS CloudTrail, así que la auditoría la tenés resuelta.

4. Correrlo desde Amazon EKS con un CronJob

Si tu plataforma ya vive en EKS, no hace falta sumar Glue ni Step Functions para esto. Un CronJob con la imagen de la AWS CLI y EKS Pod Identity alcanza. El pod lanza la operación, espera y termina con error si el resultado no es COMPLETED, así el fallo aparece en tus alertas de Kubernetes:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: catalogo-bulk-put
  namespace: data-jobs
spec:
  schedule: "0 4 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 1
      template:
        spec:
          serviceAccountName: catalogo-bulk-put   # asociada a un rol IAM con EKS Pod Identity
          restartPolicy: Never
          containers:
            - name: bulk-put
              image: amazon/aws-cli:latest          # fijá una versión >= 2.39.4
              command: ["/bin/bash", "-c"]
              args:
                - |
                  set -euo pipefail
                  LOTE=$(date -u +%F)
                  ARN=$(aws dynamodb start-bulk-put-items \
                    --table-name catalogo \
                    --input-s3-configuration "{\"S3Bucket\":\"mi-bucket-entrada\",\"S3KeyPrefix\":\"catalogo/${LOTE}/\",\"InputFormat\":\"DYNAMODB_JSON\",\"InputCompressionType\":\"GZIP\"}" \
                    --output-s3-configuration "{\"S3Bucket\":\"mi-bucket-reportes\",\"S3KeyPrefix\":\"bulk/catalogo/${LOTE}/\",\"OutputType\":[\"ERROR\",\"CONDITION_CHECK_FAILURE\"]}" \
                    --performance-tier STANDARD \
                    --client-token "catalogo-${LOTE}" \
                    --query 'BulkOperationDescription.BulkOperationArn' --output text)
                  echo "Operación: ${ARN}"
                  while true; do
                    ESTADO=$(aws dynamodb describe-bulk-operation --bulk-operation-arn "$ARN" \
                      --query 'BulkOperationDescription.BulkOperationStatus' --output text)
                    echo "$(date -u +%T) ${ESTADO}"
                    case "$ESTADO" in
                      IN_PROGRESS|CANCELLING) sleep 60 ;;
                      COMPLETED) exit 0 ;;
                      *) echo "Revisar reporte en s3://mi-bucket-reportes/bulk/catalogo/${LOTE}/"; exit 1 ;;
                    esac
                  done

Fijate que COMPLETED_WITH_ERRORS termina el Job con error a propósito. Es la recomendación más importante de AWS para esta función: tu scheduler tiene que distinguir COMPLETED de COMPLETED_WITH_ERRORS. Si no, vas a tener cargas parciales que nadie mira.

5. Reintentar solo lo que falló

El reporte ERROR trae, por cada registro, itemLocation (ARN del objeto y número de registro), errorMessages, newImage y, en fallas de condición, oldImage. El flujo de reintento es: corregís esas líneas, las dejás en un prefijo nuevo y lanzás otra operación solo con ese prefijo. Si tus líneas tienen condiciones, volver a correr el archivo entero también es seguro.

Trampas comunes

  • Una operación por tabla. No podés lanzar dos bulk put sobre la misma tabla a la vez, ni lanzarlo mientras se está creando un índice (ni al revés).
  • Límites de entrada. Hasta 50.000 objetos por operación y 5 GB por objeto. Más de eso falla con S3ObjectsCountLimitExceeded o InputObjectTooLarge.
  • La tabla tiene que estar ACTIVE. Una tabla en restauración no sirve como destino.
  • Streams. Los ítems importados se ven como escrituras normales. Si tenés Lambdas, zero-ETL o consumidores de Kinesis colgados de la tabla, dimensionalos para el tráfico normal más la carga, o frená la operación con --max-write-request-units.
  • Transacciones en curso. Si un ítem tiene una transacción en vuelo, la transacción tiene prioridad y la carga espera.
  • Claves calientes. Como cualquier escritura masiva en DynamoDB, el rendimiento depende de que las claves estén bien distribuidas. Repartí la entrada en varios objetos de tamaño parecido.

Precios

El modelo es por bytes escritos, distinto del de capacidad que conocés:

Concepto Standard Accelerated
Precio por GB escrito (us-east-1) USD 0,10 USD 0,20
Mínimo por ítem 1 KB 1 KB
Se factura por separado Tabla y cada GSI al que se propaga la escritura Tabla y cada GSI al que se propaga la escritura
Ítems rechazados por validación No se cobran No se cobran
Verificaciones de condición fallidas Se cobran Se cobran
Reporte en S3 Tarifa normal de S3 Tarifa normal de S3

El ejemplo que publica AWS: 100 millones de ítems de 1 KB son 100 GB, o sea USD 10 en Standard o USD 20 en Accelerated en us-east-1. Los precios de otras regiones y de la clase de tabla Standard-IA están en la página de precios de DynamoDB, así que si cargás en sa-east-1 verificalo ahí antes de presupuestar.

Cómo bajar el costo en la práctica:

  • Mirá los GSI. Cada índice al que se proyecta el ítem se factura aparte. Una tabla con tres GSI que proyectan todo puede multiplicar el costo. Antes de una carga grande, revisá si todos los índices hacen falta y qué proyectan.
  • Cuidado con el mínimo de 1 KB. Si tus ítems son muy chicos (por ejemplo contadores de 100 bytes), pagás como si fueran de 1 KB.
  • Filtrá antes de cargar. Las condiciones fallidas se cobran, así que no mandes un archivo con todo el catálogo si solo cambió el 5%. Generá el delta en el proceso de origen y usá las condiciones como red de seguridad, no como filtro.
  • Standard por defecto. Accelerated cuesta el doble; reservalo para cuando la ventana de tiempo realmente importa.

Disponibilidad y lo que conviene mirar con lupa

  • ¿Está en sa-east-1? Sí. Standard está en todas las regiones comerciales y en GovCloud (US). Accelerated está en 23 regiones, y South America (São Paulo) es una de ellas. Para equipos de la región, es una buena noticia: no hay que mover datos a us-east-1 para usar el nivel rápido.
  • Residencia de datos. La operación lee de S3 y escribe en la tabla; el bucket de entrada puede estar en otra cuenta (parámetro S3BucketOwner). Si tus datos no pueden salir de la región, poné el bucket de entrada y el de reportes en la misma región que la tabla.
  • Transferencia entre regiones. Si el bucket está en otra región que la tabla, tené en cuenta el costo de transferencia de S3 entre regiones además del precio por GB del bulk put. Lo más simple es tener todo en la misma región.
  • Reportes con datos. El reporte incluye newImage y oldImage, o sea copias de tus ítems. Si la tabla tiene datos sensibles, tratá el bucket de reportes con la misma política de cifrado (podés forzar KMS con S3SseAlgorithm) y ponele una regla de ciclo de vida.
  • Global tables. No soportadas en el lanzamiento. Si tu tabla es global, por ahora seguís con tu pipeline actual.

¿Vale la pena?

Para la mayoría de los equipos que hoy mantienen un job de carga masiva hacia DynamoDB, sí. El ahorro grande no está en el precio por GB sino en lo que dejás de mantener: el código de reintentos, la coordinación con el auto scaling, las alarmas silenciadas a mano durante la ventana de carga y el reporte de errores que nadie terminó de armar.

Donde lo pensaría dos veces: tablas globales (no hay soporte), cargas que en realidad son actualizaciones parciales de atributos (necesitás UpdateItem) y tablas con muchos GSI que proyectan todo, donde el costo por índice se suma rápido.

Mi lectura: lo más valioso de este lanzamiento es que AWS separó la carga masiva de la capacidad de la tabla, tanto en recursos como en métricas. Eso cambia cómo operás: tus alarmas vuelven a medir lo que importa (el tráfico real) y la carga pasa a ser un objeto con estado propio que podés monitorear, auditar y reintentar. Las condiciones por línea son el otro gran acierto, porque hacen que una carga se pueda volver a correr sin miedo, que es exactamente lo que necesitás cuando un scheduler reintenta a las 4 de la mañana. Lo que me falta es soporte para global tables y algún punto de integración con EventBridge para enterarte del final sin hacer polling; mientras tanto, el CronJob con describe-bulk-operation resuelve bien.

Marcia Villalba lo eligió como noticia principal de Desplegando.cloud esta semana, y tiene sentido: es de esas funciones que no hacen ruido en un keynote pero te ahorran un pipeline entero.

Recursos