
How to Import CSV Into DynamoDB
DynamoDB documents several ways to move a CSV into a table. Import from Amazon S3 reads CSV, DynamoDB JSON or Amazon Ion out of a bucket, up to 50,000 objects a job, and consumes no write capacity. NoSQL Workbench loads up to 150 rows into a model on a desktop. The CLI has batch-write-item, and AWS publishes a Lambda pattern that fires when an object lands in S3. Every one of those starts from a seat that already holds an AWS account. Your customer sits outside that seat, holding the rate sheet their revenue manager exported this morning.
The door a web backend has
Rows reach a DynamoDB table item by item. BatchWriteItem is the bulk form of that, and AWS states its shape plainly. "A single call to BatchWriteItem can transmit up to 16MB of data over the network, consisting of up to 25 item put or delete operations." Individual items can be up to 400 KB once stored. Every batched write a web backend sends is cut to those two numbers.
Four things it refuses, and each one moves work earlier.
It cannot update. "If you perform a BatchWriteItem operation on an existing item, that item's values will be overwritten by the operation and it will appear like it was updated." It takes no conditions, since "you cannot specify conditions on individual put and delete requests". It is not atomic as a whole, though each put and delete inside it is. And it rejects the entire batch when your request "contains at least two items with identical hash and range keys".
So nothing on the server side can tell a new rate from a changed one, refuse an overwrite, or single out one bad line. TransactWriteItems takes conditions and runs all or nothing, and it caps at 100 actions and 4 MB with no partial success to retry, so a season of rates gains nothing from it. Every decision goes in front of the write.
The files that arrive
Two properties send their October rates in the same week. Each one exports from a different system.
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | Property | Plan | Room | Stay Date | Rate | Currency Code | Min Stay | CTA |
| 2 | LIS-ALFAMA | BAR | DBL | 01/10/2026 | 145.00 | EUR | 2 | N |
| 3 | LIS-ALFAMA | BAR | DBL | 01/10/2026 | 152.00 | EUR | 2 | N |
| 4 | LIS-ALFAMA | NRF | TWN | 19/10/2026 | 129.50 | EUR | 1 | Y |
| 5 | LIS-ALFAMA | BAR | DBL | 2/10/2026 | 145.00 | EUR | 2 | N |
| 181 rows not shown | ||||||||
| 187 | LIS-ALFAMA | NRF | TWN | 31/10/2026 | 138.00 | EUR | 1 | N |
1Property,Plan,Room,Stay Date,Rate,Currency Code,Min Stay,CTA2LIS-ALFAMA,BAR,DBL,01/10/2026,145.00,EUR,2,N3LIS-ALFAMA,BAR,DBL,01/10/2026,152.00,EUR,2,N4LIS-ALFAMA,NRF,TWN,19/10/2026,129.50,EUR,1,Y5LIS-ALFAMA,BAR,DBL,2/10/2026,145.00,EUR,2,N⋮181 rows not shown187LIS-ALFAMA,NRF,TWN,31/10/2026,138.00,EUR,1,N| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | Property ID | Rate Plan Code | Room Type | Arrival Date | Amount | Currency | Minimum Nights | Closed To Arrival |
| 2 | OPO-RIBEIRA | Non Refundable | Double Room | 2026-10-01 | 118.00 | EUR | 2 | No |
| 3 | OPO-RIBEIRA | Advance Purchase 21 | Twin room | 2026-10-02 | 99.00 | EUR | 3 | Yes |
| 121 rows not shown | ||||||||
| 125 | Advance Purchase 21 | Double Room | 2026-10-03 | 104.00 | EUR | 3 | No | |
1Property ID,Rate Plan Code,Room Type,Arrival Date,Amount,Currency,Minimum Nights,Closed To Arrival2OPO-RIBEIRA,Non Refundable,Double Room,2026-10-01,118.00,EUR,2,No3OPO-RIBEIRA,Advance Purchase 21,Twin room,2026-10-02,99.00,EUR,3,Yes⋮121 rows not shown125,Advance Purchase 21,Double Room,2026-10-03,104.00,EUR,3,NoThe Alfama property calls the column Plan and the Ribeira property calls it Rate Plan Code. One writes dates day first and the other writes them ISO. BAR, NRF, DBL, TWN and CTA are the trade's shorthand. The first file prices 01/10/2026 twice, because somebody repriced and left both lines in. The last row of the second file has no property code at all.
The person drags both files into the importer inside your app. Updog Importer reads them in the browser, matches each file's headers to your schema, holds the plans and room types to your lists, and puts every row in front of them. Your onComplete handler receives the rows grouped by file. The handler posts them to a route you own, and that route writes them 25 at a time.
No Updog server stands between the browser and DynamoDB.
The table the rows land in
One table holds the calendar, and only two of its attributes are declared.
aws dynamodb create-table \ --table-name rate_calendar \ --attribute-definitions \ AttributeName=property_code,AttributeType=S \ AttributeName=rate_key,AttributeType=S \ --key-schema \ AttributeName=property_code,KeyType=HASH \ AttributeName=rate_key,KeyType=RANGE \ --billing-mode PAY_PER_REQUESTDynamoDB is schemaless past the key. AWS states that "other than the primary key attributes, you don't have to define any attributes or data types when you create tables". So rate_amount, currency and the rest exist only on the items that carry them, with whatever type the write gave them.
property_code is the partition key and rate_key is the sort key, built as PLAN#<plan>#<date> so one query returns a plan's whole month in date order. A partition key value runs to 2048 bytes and a sort key value to 1024, both far above anything here. Names are case-sensitive, so rate_key and Rate_Key are two different attributes.
One number sets the pace. "Every partition in a DynamoDB table is designed to deliver a maximum capacity of 3,000 read units per second and 1,000 write units per second", and one write unit is one write a second for an item up to 1 KB. Every row in one property's file carries the same partition key, so one property's whole import lands on one partition and 1,000 rate rows a second is its ceiling.
The template the property downloads
The key columns are the one thing DynamoDB will not accept broken. An attribute value can be an empty string when the attribute is not part of a table or index key, and a key attribute holding one is refused. The blank property code in the second file is that row.
The cheapest fix arrives before the export does. sampleData fills the "Download Example" file the import wizard offers, so the property starts from your headers instead of guessing them.
const sampleData = [ { propertyCode: "LIS-ALFAMA", planCode: "Flexible", roomType: "Double", stayDate: "2026-10-01", rateAmount: "145.00", currency: "EUR", minStay: "2", closedToArrival: "No", }, { propertyCode: "LIS-ALFAMA", planCode: "Non-refundable", roomType: "Twin", stayDate: "2026-10-02", rateAmount: "129.50", currency: "EUR", minStay: "1", closedToArrival: "Yes", },];The generated file carries your column headers and these rows. Leave sampleData out and the SDK still writes a template, carrying one generic example row generated from your column definitions. Real values do more work here, since a revenue manager reading 2026-10-01 and Non-refundable learns the date format and the plan vocabulary in one look.
The schema in Updog Importer
The columns array describes the table as the person sees it.
import type { DataEditorColumn } from "@updog/data-editor";
const PLANS = ["Flexible", "Non-refundable", "Advance purchase", "Corporate"];const ROOMS = ["Double", "Twin", "Single", "Suite"];const CURRENCIES = ["EUR", "GBP", "USD"];const YES_NO = ["Yes", "No"];
export const columns: DataEditorColumn[] = [ { id: "propertyCode", title: "Property code", size: 150, transformer: (value) => String(value).trim().toUpperCase(), validators: [ { type: "required" }, { type: "regex", pattern: "^[A-Z]{3}-[A-Z]+$" }, ], }, { id: "planCode", title: "Plan code", size: 170, editor: { type: "select", options: PLANS, enableCustomValue: false }, validators: [{ type: "required" }, { type: "oneOf", values: PLANS }], }, { id: "roomType", title: "Room type", size: 130, editor: { type: "select", options: ROOMS, enableCustomValue: false }, validators: [{ type: "oneOf", values: ROOMS }], }, { id: "stayDate", title: "Stay date", size: 140, editor: { type: "date" }, validators: [ { type: "required" }, { type: "date" }, ], }, { id: "rateAmount", title: "Rate amount", size: 140, editor: { type: "number" }, validators: [ { type: "required" }, { type: "number", min: 0, max: 100_000, decimalPlaces: 2 }, ], }, { id: "currency", title: "Currency", size: 120, editor: { type: "select", options: CURRENCIES, enableCustomValue: false }, }, { id: "minStay", title: "Min stay", size: 120, editor: { type: "number" }, validators: [{ type: "number", min: 1, max: 30, decimalPlaces: 0 }], }, { id: "closedToArrival", title: "Closed to arrival", size: 160, editor: { type: "select", options: YES_NO, enableCustomValue: false }, validators: [{ type: "oneOf", values: YES_NO }], },];Each editor earns its place against what arrives. The date editor turns 19/10/2026 into 2026-10-19, and since 19 is above 12 that file settles day first, so 01/10/2026 and 2/10/2026 land as 2026-10-01 and 2026-10-02. The select editors hold the plan, the room type and the arrival flag to fixed lists, and a value nobody maps is dropped from the row. { type: "required" } on the property code catches the blank cell while somebody can still type into it.
The headers each property sends
Column matching repeats per file. Each file gets its own screen and its own mapping, so the same schema column is fed by a different header in each one.
| Header | File | Reaches | How |
|---|---|---|---|
Property |
Alfama | propertyCode |
contains, 80 |
Property ID |
Ribeira | propertyCode |
shared word, 70 |
Plan |
Alfama | planCode |
contains, 80 |
Rate Plan Code |
Ribeira | planCode |
contains, 80 |
Room |
Alfama | roomType |
contains, 80 |
Stay Date |
Alfama | stayDate |
exact, 100 |
Arrival Date |
Ribeira | stayDate |
shared word, 70 |
Rate |
Alfama | rateAmount |
contains, 80 |
Amount |
Ribeira | rateAmount |
contains, 80 |
Currency Code |
Alfama | currency |
synonym, 90 |
Minimum Nights |
Ribeira | minStay |
synonym, 90 |
CTA |
Alfama | closedToArrival |
synonym, 90 |
Plan and Room sit exactly on the contains floor, which fires once the shorter string runs to four characters. Currency Code needs no configuration, since the built-in synonym table already carries it. CTA is three characters and reaches nothing on its own, and Minimum Nights shares no whole word with Min stay, while normalized it runs six characters longer than minstay, twice what the edit-distance tier allows at that length. Both come in through one synonyms entry each.
Value matching runs once for the whole import and collects values per schema column across both files. Double Room and Twin room contain their options and land at eighty. Y and N reach Yes and No at ninety, since the built-in table already carries them, and TWN lands on Twin at sixty-five through the edit-distance tier. BAR, NRF and DBL reach nothing, so one synonyms entry per option carries the trade's shorthand.
Each file also settles its own dates. The verdict comes from a sample of that file alone, so a day-first export and an ISO export in the same import never borrow each other's reading.
The mount
The props tie the two files, the schema and the table together.
<DataEditor<Rate> apiKey="your-license-key" open={open} onClose={closeEditor} columns={columns} sampleData={sampleData} primaryKey={["propertyCode", "planCode", "stayDate"]} enableDeleteRow="all" blockSubmitOnError synonyms={{ columns: { minStay: ["minimum nights", "min nights", "los", "length of stay"], closedToArrival: ["cta", "no arrival", "arrival closed"], }, values: { Flexible: ["bar", "best available rate", "rack"], "Non-refundable": ["nrf", "nr"], Double: ["dbl", "db"], Twin: ["twn", "tw"], }, }} onComplete={onComplete}/>primaryKey takes three columns, because a rate is identified by a property, a plan and a night together. Values are compared after trimming, and a row missing any part merges with nothing and arrives as new.
enableDeleteRow="all" lets the person drop the second 01/10/2026 line by hand once the grid shows both. blockSubmitOnError keeps submit disabled while any row carries an error, the blank property code included. Whatever the person fixes by hand comes back on the result as learnedSynonyms, ready to store and feed back through synonyms next season.
Every snippet here is React. Those props reach Vue, Angular and Svelte through the web component build. The install and the modal wiring beneath this mount live in how to import a CSV file into a React app.
The result on submit
On submit, Updog Importer hands your handler every row grouped by source. Each file lands as its own entry, carrying the file name, so the row that came from Ribeira still says so on the way out.
import type { DataEditorResult, ResultRow } from "@updog/data-editor";
const CHUNK_SIZE = 500;
const toRow = (entry: ResultRow<Rate>, sourceFile: string) => { if (entry.isDeleted && entry.isNew) return []; return [{ property_code: entry.row.propertyCode, plan_code: entry.row.planCode, room_type: entry.row.roomType, stay_date: entry.row.stayDate, rate_amount: Number(entry.row.rateAmount), currency: entry.row.currency, min_stay: entry.row.minStay ? Number(entry.row.minStay) : undefined, closed_to_arrival: entry.row.closedToArrival === "Yes", source_file: sourceFile, op: entry.isDeleted ? "delete" : "put", }];};
const onComplete = useCallback(async (result: DataEditorResult<Rate>) => { for (const source of result.sources) { const rows = source.rows.flatMap((entry) => toRow(entry, source.sourceName));
for (let start = 0; start < rows.length; start += CHUNK_SIZE) { const written = await fetch("/api/rates/write", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rows: rows.slice(start, start + CHUNK_SIZE) }), }); if (!written.ok) throw new Error((await written.json()).message); } }}, []);Three flags become one field. A new rate and a changed rate are the same payload, since BatchWriteItem overwrites either way. A row the person added and then deleted goes nowhere. Nothing here loads from your backend, so every row arrives new and op reads put on all of them. The delete branch waits on the season you feed the existing rates in through loadData.
DynamoDB stores the type you send. Numbers travel to the API as strings and are treated as numbers once they land, and a value the browser hands over as text stays text on the item forever. Number() around the rate and the minimum stay is what keeps them comparable later. The boolean is built here for the same reason, since "Yes" is what the person reads and true is what a filter can use.
The chunk size belongs to you. AWS publishes 25 items a BatchWriteItem call and 1,000 write units a second per partition, and neither number describes a POST body. So five hundred rows is a number chosen against what your own endpoint accepts, and it fills half a second of one partition's budget. The route splits it into the published 25.
Throw when a route answers with a failure. Updog holds submit until your promise resolves, then empties the editor. A handler that traps the error and returns counts as a finished import, and both properties' rates clear the grid unwritten. A thrown error keeps the grid as it stands, with every mapping and hand correction on it. The person submits again on rows that never left the screen.
The route that writes
The route holds the credentials, and the browser stops there.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";import { DynamoDBDocumentClient, BatchWriteCommand } from "@aws-sdk/lib-dynamodb";
const TABLE = "rate_calendar";const BATCH = 25;const ATTEMPTS = 6;
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}), { marshallOptions: { removeUndefinedValues: true },});
const rateKey = (row) => "PLAN#" + row.plan_code + "#" + row.stay_date;
const dedupe = (rows) => { const latest = new Map(); for (const row of rows) latest.set(row.property_code + "|" + rateKey(row), row); return [...latest.values()];};
const toRequest = (row) => { const key = { property_code: row.property_code, rate_key: rateKey(row) }; if (row.op === "delete") return { DeleteRequest: { Key: key } }; return { PutRequest: { Item: { ...key, plan_code: row.plan_code, room_type: row.room_type, stay_date: row.stay_date, rate_amount: row.rate_amount, currency: row.currency, min_stay: row.min_stay, closed_to_arrival: row.closed_to_arrival, source_file: row.source_file, updated_at: new Date().toISOString(), }, }, };};getVerifiedSession() stands in for your own server-side authentication check. The property codes arrive in the body, so the route checks each one against the properties that session is allowed to load. A guessed code reaches no other hotel's calendar.
dedupe is there because DynamoDB rejects the whole batch when two items carry identical keys, and Updog cannot catch that one for you. { type: "unique" } is a single-column rule, checked against every other row in the same column, and this key spans three. So the person sees both 01/10/2026 lines and can delete one, and the map keeps the last of any pair that survives.
The sort key is composed here, from the plan and the date the person already settled. One place in your code builds that string, so the write and any later query agree on it.
The 200 that wrote nothing
A BatchWriteItem call answers HTTP 200 and can still leave items unwritten.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const writeBatch = async (requests) => { let pending = requests;
for (let attempt = 0; attempt < ATTEMPTS && pending.length > 0; attempt++) { if (attempt > 0) await wait(2 ** attempt * 50 + Math.random() * 50);
const answer = await client.send( new BatchWriteCommand({ RequestItems: { [TABLE]: pending } }), ); pending = answer.UnprocessedItems?.[TABLE] ?? []; }
if (pending.length > 0) { throw new Error(pending.length + " items were handed back unwritten"); }};
app.post("/api/rates/write", async (request, response) => { const session = await getVerifiedSession(request); if (!session) return response.status(401).json({ message: "Not signed in" });
const rows = dedupe(request.body.rows); const allowed = await propertiesFor(session.accountId); if (rows.some((row) => !allowed.has(row.property_code))) { return response.status(403).json({ message: "A property code is not yours" }); }
try { for (let start = 0; start < rows.length; start += BATCH) { await writeBatch(rows.slice(start, start + BATCH).map(toRequest)); } } catch (error) { request.log.error({ err: error }); return response.status(502).json({ message: "The write did not finish" }); }
response.json({ written: rows.length });});AWS names the mechanism directly. Failed operations "are returned in the UnprocessedItems response parameter", the value "is in the same form as RequestItems, so you can provide this value directly to a subsequent BatchWriteItem operation", and a clean call "contains an empty UnprocessedItems map". So the loop reads what came back and sends it again.
AWS asks for one thing on that retry. "we strongly recommend that you use an exponential backoff algorithm", because an immediate retry meets the same throttling that produced the leftovers. Throttling that stops the whole call raises ProvisionedThroughputExceededException or ThrottlingException instead, and both come back as errors your try already catches. Importing a CSV into MongoDB shows the other document store's answer to the same problem, where a failed bulk write names each rejected row by index.
All of this happens with the confirm dialog open and a spinner on the button. Anything on the result worth keeping gets copied inside the handler, since the editor drops its rows, its sources, its history and its learned synonyms once the promise resolves.
The parts nobody ships for you
Updog Importer integrates with nobody. There is no DynamoDB connector, no destination list, no webhook and no server of ours. onComplete hands your code an object, and the route in the middle is work you do. Our uniqueness rule reads one column, so a multi-column key is checked by your own code.
DynamoDB already ships its own way in for the other case. Import from Amazon S3 reads CSV, DynamoDB JSON or Amazon Ion, runs 50,000 objects a job, and consumes no write capacity on the table it fills. It also states that "Import into existing tables is not currently supported by this feature", and a repeat customer import is exactly a write into a table that already exists. For a one-off load your own team assembled, that import is the shorter way in. Everything above exists for the rate sheets your properties send, opened in a browser inside a session your app issued. Client-side and server-side CSV import names the jobs each of the two models fits.
The pieces you wrote and next season
You wrote one table with two declared attributes, a schema with eight columns, a downloadable template, one synonyms block covering the trade's shorthand, a dedupe map, and one route that writes 25 items at a time and retries what comes back. The files stay on the machine that opened them. The rows travel from your own front end to your own route and into DynamoDB, and the only party you added to the chain is yourself. Point the same setup at a React CSV importer modal or at the web component and the middle stays the same.
Next season brings the same two properties and a third one with a third spelling of Closed to arrival. The mappings from October are already stored, the template goes out with the request for rates, and the new property costs one more line in synonyms.