
How to Import CSV Into Snowflake
A trial balance is every account in one client's ledger for one period, with a debit or a credit against each account. The two columns agree to the cent, and an audit platform that loads one into Snowflake has to keep that true. The file comes out of the client's accounting system, which the audit firm neither runs nor controls.
One month of one European subsidiary arrives looking like this.
| A | B | C | D | E | F | G | |
|---|---|---|---|---|---|---|---|
| 1 | Account No | Account Name | Period | Debit | Credit | Currency | Entity |
| 2 | 1010 | Cash at bank | 2026-06 | 1.284.900,00 | - | EUR | Alpen Handel GmbH |
| 3 | 1400 | Trade receivables | 2026-06 | 842.317,55 | - | EUR | Alpen Handel GmbH |
| 4 | 2100 | Trade payables | 2026-06 | - | (97,881.64) | EUR | Alpen Handel GmbH |
| 5 | 4000 | Revenue | 2026-06 | - | (2.126.773,11) | EUR | Alpen Handel GmbH |
| 6 | 6200 | Office rent | 2026-06 | 96 000,00 | - | EUR | Alpen Handel GmbH |
| 7 | 7300 | Bank charges | 2026-06 | EUR 1.437,20 | - | EUR | Alpen Handel GmbH |
| 207 rows not shown | |||||||
| 215 | 9900 | Retained earnings | 2026-06 | - | (412.660,90) | EUR | Alpen Handel GmbH |
1Account No,Account Name,Period,Debit,Credit,Currency,Entity21010,Cash at bank,2026-06,"1.284.900,00",-,EUR,Alpen Handel GmbH31400,Trade receivables,2026-06,"842.317,55",-,EUR,Alpen Handel GmbH42100,Trade payables,2026-06,-,"(97,881.64)",EUR,Alpen Handel GmbH54000,Revenue,2026-06,-,"(2.126.773,11)",EUR,Alpen Handel GmbH66200,Office rent,2026-06,"96 000,00",-,EUR,Alpen Handel GmbH77300,Bank charges,2026-06,"EUR 1.437,20",-,EUR,Alpen Handel GmbH⋮207 rows not shown2159900,Retained earnings,2026-06,-,"(412.660,90)",EUR,Alpen Handel GmbHSeven headers, and the two amount columns hold six ways of writing a number. Two debits read 1.284.900,00, with the dot grouping thousands and the comma marking the decimal. One credit reads (2.126.773,11), the same convention inside accounting parentheses. The other credit reads (97,881.64), with those two separators swapped. One debit groups with a space, 96 000,00. One writes its currency in letters, EUR 1.437,20, beside a Currency column that already says EUR. A dash stands for a zero balance on every line carrying no amount.
That trial balance reaches the importer when the person drops it into your audit platform. Updog Importer stays in the browser, matches those seven headers to your fields, and puts the whole ledger on screen. Your onComplete handler receives the rows, checks that the two totals agree, and posts them in chunks to a route you own. The route holds the Snowflake key, writes the chunk into a staging table, and runs one MERGE.
We run no server between that browser and your Snowflake account.
Step 1. Create the target table and its staging table
Two tables, because the write happens in two moves.
create table ledger.trial_balance ( engagement_id varchar not null, period varchar(7) not null, account_no varchar(10) not null, account_name varchar not null, debit number(18, 2), credit number(18, 2), currency varchar(3) not null, entity varchar not null, loaded_at timestamp_ntz not null, constraint trial_balance_pk primary key (engagement_id, period, account_no));
create transient table ledger.trial_balance_stage ( batch_id varchar not null, engagement_id varchar not null, period varchar(7) not null, account_no varchar(10) not null, account_name varchar not null, debit number(18, 2), credit number(18, 2), currency varchar(3) not null, entity varchar not null, loaded_at timestamp_ntz not null);The primary key names the identity of one line, which takes an engagement, a period and an account number together. A standard Snowflake table leaves that constraint unenforced, along with FOREIGN KEY and UNIQUE, and enforces NOT NULL and CHECK. Enforcing a primary key is reserved for hybrid tables. So a second import of the same file lands a second copy of every row, and the table itself stays quiet about it. The MERGE in step 6 is what closes that hole.
The staging table is transient, which means it carries no Fail-safe period and no Fail-safe cost. Snowflake describes a transient table as built for data that outlives a session and needs less protection than a permanent table. A batch of rows waiting for a merge is exactly that.
Step 2. Describe the ledger to Updog Importer
The columns array is your table written for the person reading the file. Each entry sets a title they see, an editor that decides how a cell is typed, and validators that mark what fails.
import type { DataEditorColumn } from "@updog/data-editor";
const CURRENCIES = ["EUR", "CHF", "GBP"];
const amount = (value: unknown) => { const text = String(value).trim(); if (text === "" || text === "-") return ""; return text.startsWith("-") ? text.slice(1) : text;};
export const columns: DataEditorColumn[] = [ { id: "accountNo", title: "Account no", size: 120, transformer: (value) => String(value).trim(), validators: [ { type: "required" }, { type: "regex", pattern: "^\\d{4}$" }, { type: "unique" }, ], }, { id: "accountName", title: "Account name", size: 220, validators: [{ type: "required" }], }, { id: "period", title: "Period", size: 110, validators: [ { type: "required" }, { type: "regex", pattern: "^\\d{4}-\\d{2}$" }, ], }, { id: "debit", title: "Debit", size: 150, editor: { type: "number" }, transformer: amount, validators: [{ type: "number", min: 0, decimalPlaces: 2 }], dependentFields: ["credit"], }, { id: "credit", title: "Credit", size: 150, editor: { type: "number" }, transformer: amount, validators: [ { type: "number", min: 0, decimalPlaces: 2 }, { type: "function", fn: (value, row) => !value && !row.debit ? { level: "error", message: "Every line carries a debit or a credit" } : null, }, ], }, { id: "currency", title: "Currency", size: 110, validators: [{ type: "required" }, { type: "oneOf", values: CURRENCIES }], }, { id: "entity", title: "Entity", size: 200, validators: [{ type: "required" }], },];accountNo carries unique, which flags a second line for the same account. That flag is load-bearing. Two source rows for a key the target already holds reach Snowflake's nondeterministic case, and ERROR_ON_NONDETERMINISTIC_MERGE defaults to TRUE, so the statement returns an error. On the first load of that key the pair takes the WHEN NOT MATCHED branch, which Snowflake documents as deterministic, so both rows land and the duplicate survives. The function validator on credit flags a line carrying neither amount, and dependentFields re-runs it whenever the debit beside it changes.
The mount ties that schema to the wizard and teaches matching the words accountants already use.
<DataEditor<TrialBalanceRow> apiKey="your-license-key" variant="uploader" open={open} onClose={closeImporter} columns={columns} primaryKey="accountNo" blockSubmitOnError synonyms={{ columns: { accountNo: ["account no", "gl code", "nominal code"], accountName: ["account name", "gl description", "narrative"], }, }} onComplete={onComplete}/>Column synonyms map GL Code and Nominal Code onto accountNo, so the next firm's export lands without a hand mapping. blockSubmitOnError keeps the submit button disabled while any cell is flagged, which is how a duplicate account number stays out of the staging table. primaryKey is accountNo on its own, because the engagement and the period are chosen in your app before the importer opens. How to import a CSV file into a React app has the modal state and the install this snippet leaves out.
The front-end snippets are React. The web component build takes the same props, so the same schema and the same handler work in Vue, Angular or Svelte.
Step 3. Read the numbers the file carries
Updog picks one number format for a file, and it picks it from the file. A value holding both a dot and a comma settles the question on its own, because the last separator is the decimal. 1.284.900,00 puts the comma last, so the whole file reads as European. The scan covers the headers mapped to number columns alone, so account codes and periods cast no vote.
From that verdict every amount is rewritten. 1.284.900,00 becomes 1284900.00. 96 000,00 becomes 96000.00, since a space grouping in strict three-digit blocks collapses. (2.126.773,11) becomes -2126773.11, since accounting parentheses peel off and set the sign. Currency symbols and percent signs peel off the same way.
Two shapes survive untouched, by design. EUR 1.437,20 keeps its letters, because stripping letters would turn an identifier like abc123 into 123. The dash keeps itself, because a bare sign leaves nothing numeric behind. Both stay exactly as written, and the number validator flags the cell holding letters.
The transformer on both amount columns runs after that rewrite. It receives -2126773.11 and drops the sign, because in this export the parentheses mark a credit. It receives the dash and returns an empty string. The number rule passes an empty value, and the function rule beside it is what flags a line carrying neither amount. So the grid opens with one red cell, EUR 1.437,20, and the person types 1437.20 over it.
One value gets through looking fine. (97,881.64) was written in the convention the file lost. Under the European verdict its dot disappears and its comma becomes the decimal point, which gives -97.88164. That is a finite number, so it is kept, and number passes it. A trade payable of ninety-seven euros now sits in a column of six-figure amounts. Common CSV import errors covers more values that parse and still lie.
Step 4. Check the balance before the rows leave the browser
An amount that parses and still lies is what the balance check exists for. Updog Importer groups the submitted rows by source and passes them to your handler, every ledger line tagged isNew, isChanged, isDeleted and isValid. A person can upload several files in one pass, and each file arrives as its own source entry. The handler flattens them before it sums.
import type { DataEditorResult } from "@updog/data-editor";
const CHUNK_SIZE = 1000;
const total = (rows: TrialBalanceRow[], field: "debit" | "credit") => rows.reduce((sum, row) => sum + Number(row[field] || 0), 0);
const onComplete = useCallback( async (result: DataEditorResult<TrialBalanceRow>) => { const rows = result.sources .flatMap((source) => source.rows) .map((entry) => entry.row);
const debits = total(rows, "debit"); const credits = total(rows, "credit"); if (Math.abs(debits - credits) > 0.005) { throw new Error( "Debits reach " + debits.toFixed(2) + " and credits reach " + credits.toFixed(2), ); }
for (let start = 0; start < rows.length; start += CHUNK_SIZE) { const response = await fetch("/api/trial-balance/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ engagementId, period, rows: rows.slice(start, start + CHUNK_SIZE), }), });
if (!response.ok) { const failure = await response.json(); throw new Error(failure.message); } } }, [engagementId, period],);The two totals are compared before the first request goes out. Debits reach 2,224,654.75. Credits reach 2,126,870.99, which is 97,783.76 short, and that difference is the mis-parsed payable to the cent. The handler throws with both numbers, and the editor keeps every row on the screen.
Throw when the write fails. Updog awaits your handler and clears the editor the moment it resolves. A handler that keeps the error to itself reads like a merge that landed, and those ledger lines leave the screen unwritten. Throwing keeps every row, every mapping and every fix in place, so the person presses submit again on data they can still see. Once the promise resolves, the editor drops its rows, its sources, its history and its learned synonyms. Anything on the result you want to keep gets copied inside the handler first.
A thousand rows a request is a conservative choice of ours, and two published Snowflake numbers sit well above it. One row of this schema serializes to 147 bytes of JSON.
{"accountNo":"1010","accountName":"Cash at bank","period":"2026-06","debit":"1284900.00","credit":"","currency":"EUR","entity":"Alpen Handel GmbH"}A thousand of those reach 147,000 bytes. Snowflake recommends holding query text to 1 MB a statement, and says that figure covers a whole batch of bound data. A single VALUES clause caps at 200,000 rows. Both ceilings are far away, so the number is picked for the person watching a spinner. The confirm dialog holds the whole round trip open until the last chunk lands.
Step 5. Write the chunk from your own route
The route is where the Snowflake key lives and where the browser stops.
import crypto from "node:crypto";import express from "express";import snowflake from "snowflake-sdk";
const pool = snowflake.createPool( { account: process.env.SNOWFLAKE_ACCOUNT, username: process.env.SNOWFLAKE_USER, role: "IMPORT_WRITER", warehouse: "IMPORT_WH", database: "AUDIT", schema: "LEDGER", authenticator: "SNOWFLAKE_JWT", privateKeyPath: process.env.SNOWFLAKE_KEY_PATH, privateKeyPass: process.env.SNOWFLAKE_KEY_PASS, }, { max: 10, min: 0 },);
const run = (conn, sqlText, binds) => new Promise((resolve, reject) => { conn.execute({ sqlText, binds, complete: (err, stmt, rows) => (err ? reject(err) : resolve(rows)), }); });
const app = express();app.use(express.json({ limit: "5mb" }));
app.post("/api/trial-balance/import", async (request, response) => { const session = await getVerifiedSession(request); if (!session) return response.status(401).json({ message: "Not signed in" });
const { engagementId, period, rows } = request.body; const engagement = await loadEngagement(engagementId, session.firmId); if (!engagement) return response.status(403).json({ message: "No access" });
const stray = rows.find((row) => row.period !== period); if (stray) { return response.status(400).json({ message: "Account " + stray.accountNo + " carries period " + stray.period, }); }
const batchId = crypto.randomUUID(); const loadedAt = new Date().toISOString(); const binds = rows.map((row) => [ batchId, engagementId, period, row.accountNo, row.accountName, row.debit === "" ? null : Number(row.debit), row.credit === "" ? null : Number(row.credit), row.currency, row.entity, loadedAt, ]);
try { await pool.use(async (conn) => { await run(conn, STAGE_INSERT, binds); await run(conn, MERGE_BATCH, [batchId]); await run(conn, STAGE_CLEAR, [batchId]); }); } catch (error) { return response.status(502).json({ message: error.message }); }
return response.json({ merged: rows.length });});getVerifiedSession and loadEngagement stand in for your own checks. The engagement comes off the session and the request body, and never off a row. So the whole chunk lands under one engagement this firm can open. Every row's period is compared against the period the app chose, and a mismatch fails the chunk with the account number that caused it.
The connection authenticates with a key pair. Snowflake documents key pairs as an alternative to basic authentication, asks for a 2048-bit RSA key as a minimum, and registers the public half through ALTER USER example_user ADD KEY PAIR my_key PUBLIC_KEY='...', which carries a name, an optional role restriction and an optional expiry. Setting the user's RSA_PUBLIC_KEY property is the older path and carries none of the three. The Node.js driver wants authenticator set to SNOWFLAKE_JWT and either the key itself or a path to it. The private key stays on your server.
The JSON body parser Express bundles defaults to a 100kb limit and answers a larger body with 413 and entity.too.large, so this route raises it on purpose. A thousand rows of 147 bytes leave room to spare inside 5 MB.
Step 6. Merge the batch into the trial balance
Three statements do the write, and the middle one is the reason for the other two.
-- STAGE_INSERTinsert into ledger.trial_balance_stage (batch_id, engagement_id, period, account_no, account_name, debit, credit, currency, entity, loaded_at)values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-- MERGE_BATCHmerge into ledger.trial_balance as tusing ( select * from ledger.trial_balance_stage where batch_id = ?) as s on t.engagement_id = s.engagement_id and t.period = s.period and t.account_no = s.account_nowhen matched then update set t.account_name = s.account_name, t.debit = s.debit, t.credit = s.credit, t.currency = s.currency, t.entity = s.entity, t.loaded_at = s.loaded_atwhen not matched then insert (engagement_id, period, account_no, account_name, debit, credit, currency, entity, loaded_at) values (s.engagement_id, s.period, s.account_no, s.account_name, s.debit, s.credit, s.currency, s.entity, s.loaded_at);
-- STAGE_CLEARdelete from ledger.trial_balance_stage where batch_id = ?;The insert is one statement carrying an array of rows, which is the shape the Node.js driver documents for a bulk insertion. Each chunk gets its own batch id, so the MERGE reads only the rows this request wrote. WHEN MATCHED updates a line already sitting under this engagement, period and account. WHEN NOT MATCHED inserts the rest. Send the same file again and the same six rows are rewritten, so the row count stays at six and the later file wins. That is the behaviour the unenforced primary key leaves to you.
The delete afterwards drops the batch from the staging table. Rows left behind by a request that failed halfway carry a different batch id, so no later merge reads them.
A warehouse has to be running before any of this executes. Snowflake requires one for queries and for every DML operation, loading included. Auto-resume defaults to TRUE, so the first statement starts it, and auto-suspend defaults to 600 seconds. Billing runs per second with a 60-second minimum each time the warehouse starts.
Both settings and the ceiling on a statement sit on the warehouse this route uses.
alter warehouse import_wh set auto_resume = true, auto_suspend = 60, statement_timeout_in_seconds = 120;STATEMENT_TIMEOUT_IN_SECONDS defaults to 172800, which is two days, so it caps nothing a person would sit through. Setting it to 120 turns a hung statement into an error the route can answer with. The person is still watching when that answer arrives. Auto-suspend at 60 seconds gives this warehouse a shorter idle life than the default ten minutes. It wakes for one import and goes back to sleep.
Step 7. Send the failure back up the chain
The Node.js driver reports a failure through the complete callback. The run helper in step 5 turns that callback into a rejected promise, so one try covers all three statements. The message reaches the browser as JSON, the handler throws it, and the grid stays exactly as it was.
Chunks that already merged stay merged. A retry re-sends every chunk, and the MERGE writes the same values over the rows that landed, so a repeat costs a rewrite of what already exists.
A duplicate account number inside one chunk is the failure the grid heads off. Snowflake returns an error when one target row matches two source rows, on the default ERROR_ON_NONDETERMINISTIC_MERGE. The unique validator on accountNo and blockSubmitOnError together keep that chunk from being built at all.
The row count that picks the path
Updog Importer integrates with nobody. There is no Snowflake connector, no destination list, no webhook and no server of ours. Updog calls onComplete with one result object, and everything between it and the MERGE is work you own.
Snowflake already ships its own import for the other case. The Load Data wizard in Snowsight takes a file up to 250 MB, and up to 250 files at a time. Its documentation points larger loads at the Snowflake CLI or SnowSQL. PUT uploads a file from a local file system onto an internal stage, and COPY INTO loads that stage into a table. Snowflake asks for staged files of roughly 100 to 250 MB compressed. Both paths start from a Snowflake seat. PUT also needs the file to sit on the machine running the driver, and rows arriving as a JSON request body are no such file. Taking that route means writing them to disk first.
Row count decides between the two. A thousand trial balance lines fit in one statement well inside Snowflake's 1 MB recommendation. A hundred million rows of transaction detail belong in a staged file and a COPY INTO. That path also skips a file it has already loaded, for the 64 days its load metadata lives. A file your own systems produced takes the shorter road through Snowsight or a stage. Every step above serves a trial balance the client produced, opened by a browser under a session your own app started. Client-side and server-side CSV import compares the two models job by job.
The period sent twice
You wrote two tables, a schema of seven columns, a handler that checks a balance and chunks, and a route that holds one key pair. The trial balance never leaves the machine the person opened it on. The rows travel from your own front end to your own route, into a staging table, and out of it through one MERGE. The only party added to the chain is you. The same middle serves a plain JavaScript CSV importer page, since the web component takes the props the React component takes.
If the client sends that period again, the second file corrects the first.