Uploading and processing CSV files via a REST API
Send a CSV file as a multipart form field named file to POST /upload, then read the imported
records from GET /data. This walkthrough builds those endpoints with Express and Multer, using
csv-parse to turn CSV into validated JSON records. A successful import replaces the previous
dataset; a rejected import leaves it intact.
The example stores one shared dataset in memory and listens only on your local machine. It is a small import demonstration: restarting the server clears the data, and there is no login or per-user storage.
Prerequisites
Use Node.js 24.x or 26.x, Corepack with Yarn 4.12.0 available, and a Bash-compatible terminal. The
commands also need cURL 7.76.0 or newer for --fail-with-body. The example was tested on macOS with
Node.js 24.21.0 and 26.8.2, Yarn 4.12.0, and cURL 8.7.1.
Our CSV format has the header name,age, in that order, and at least one data row. Names must be
nonblank; ages must contain one to three decimal digits and fall between zero and 130. The importer
trims surrounding whitespace in values and converts ages to JSON numbers. Use comma-separated
UTF-8 input; a UTF-8 byte order mark, quoted commas, and quoted newlines are supported. Empty lines
are skipped.
Setting up a Node.js and Express Server
Run this from a directory where you want the new csv-upload-api folder. The subshell leaves your
current directory unchanged. If that folder already exists, the command stops without installing
packages or overwriting its files; choose a fresh location to repeat the setup.
(
mkdir csv-upload-api &&
cd csv-upload-api &&
printf '{"name":"csv-upload-api","private":true,"packageManager":"yarn@4.12.0"}\n' > package.json &&
touch yarn.lock &&
corepack yarn config set nodeLinker node-modules &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 csv-parse@7.0.2
)
The empty lockfile makes this an independent Yarn project, including when you create it inside
another project. Keep the generated yarn.lock with your example to retain the resolved dependency
versions. Multer 2.4.0 includes the fix for an aborted-upload disk cleanup vulnerability;
keep this dependency patched when adapting the example.
Implementing the file upload endpoint
Create server.js inside the new csv-upload-api directory with the complete code below. Multer
accepts one file, up to 5 MiB, and no extra form fields. Its
single('file') middleware
writes a temporary file and exposes it as req.file.
const fs = require('node:fs')
const path = require('node:path')
const { pipeline } = require('node:stream/promises')
const { CsvError, parse } = require('csv-parse')
const express = require('express')
const multer = require('multer')
const app = express()
const port = Number(process.env.PORT || 3100)
const upload = multer({
dest: path.join(__dirname, 'uploads'),
limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 0, parts: 1 },
})
class InvalidCsv extends Error {}
let storedData = []
async function parseUpload(filePath) {
try {
const results = []
await pipeline(
fs.createReadStream(filePath),
parse({
bom: true,
skip_empty_lines: true,
max_record_size: 64 * 1024,
columns(header) {
if (header.length !== 2 || header[0] !== 'name' || header[1] !== 'age') {
throw new InvalidCsv('Expected the header name,age.')
}
return header
},
}),
async (rows) => {
for await (const row of rows) {
const name = row.name.trim()
const age = row.age.trim()
if (name === '' || !/^\d{1,3}$/.test(age) || Number(age) > 130) {
throw new InvalidCsv('Each row needs a name and an integer age from 0 to 130.')
}
if (results.length === 10000) {
throw new InvalidCsv('At most 10,000 records are allowed.')
}
results.push({ name, age: Number(age) })
}
},
)
if (results.length === 0) {
throw new InvalidCsv('Include at least one data row.')
}
return results
} finally {
await fs.promises.unlink(filePath)
}
}
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Send a CSV file in the file field.' })
}
const results = await parseUpload(req.file.path)
storedData = results
res.json({ recordsStored: results.length })
})
app.get('/data', (req, res) => {
res.json(storedData)
})
// Express recognizes error middleware by its four arguments; register it after the routes.
app.use((error, req, res, next) => {
if (res.headersSent) return next(error)
if (error instanceof multer.MulterError) {
const tooLarge = error.code === 'LIMIT_FILE_SIZE'
return res.status(tooLarge ? 413 : 400).json({
error: tooLarge ? 'The file exceeds 5 MiB.' : 'Send one file field and no other fields.',
})
}
if (error instanceof InvalidCsv) {
return res.status(400).json({ error: error.message })
}
if (error instanceof CsvError) {
return res.status(400).json({ error: 'Invalid CSV syntax or record too large.' })
}
console.error('CSV import failed because of an unexpected server error.')
res.status(500).json({ error: 'Unable to import the file.' })
})
app.listen(port, '127.0.0.1', () => {
console.log(`CSV API listening on http://127.0.0.1:${port}`)
})
The endpoint checks the content regardless of the filename or MIME type supplied by the client.
Those labels do not establish that a file contains valid CSV. A file called data.csv still has to
pass the parser and row checks.
Parsing and storing CSV data
The parser’s columns callback checks the header before
creating record objects. Requiring exactly name,age rejects duplicate, missing, and unexpected
columns. The parser rejects inconsistent row lengths and unclosed quoted fields,
and the loop validates the values before adding them to the new dataset.
pipeline waits for reading, parsing, and row validation to finish. The finally block then removes
the temporary file, including when parsing fails. Only after that does /upload replace
storedData. Express 5 forwards rejected async route promises
to the error middleware, so a filesystem failure returns a generic 500 response instead of a parser
message or local path.
Besides the 5 MiB file limit, max_record_size
bounds the parser’s record buffers, and the loop caps output at 10,000 records. Parsing uses
streams, but the completed records still occupy memory. During a replacement, both the previous
dataset and the new records can be present. Concurrent uploads are independent; the last one to
finish successfully wins.
Testing the API
From the directory containing csv-upload-api, start the server in one terminal:
(cd csv-upload-api && node server.js)
Leave it running and use a second terminal in that same parent directory. This command creates
data.csv inside the project. noclobber makes it refuse to overwrite an existing file, including
on a noninteractive rerun.
(
cd csv-upload-api &&
set -o noclobber &&
cat > data.csv <<'CSV'
name,age
"Doe, Jane",34
Tim,42
CSV
)
Upload that file. -F builds a multipart request; file must match the server’s field name. Let
cURL supply the multipart boundary rather than setting the request’s Content-Type header yourself.
curl --fail-with-body --silent --show-error \
-F 'file=@csv-upload-api/data.csv;type=text/csv' http://127.0.0.1:3100/upload
The response is {"recordsStored":2}. Fetch the stored records:
curl --fail-with-body --silent --show-error http://127.0.0.1:3100/data
[{"name":"Doe, Jane","age":34},{"name":"Tim","age":42}]
Now send a row missing its age. This request reads CSV from standard input, so it does not create or replace a local file:
curl --fail-with-body --silent --show-error --write-out '\nHTTP %{http_code}\n' \
-F 'file=@-;filename=invalid.csv;type=text/csv' http://127.0.0.1:3100/upload <<'CSV'
name,age
Tim
CSV
Expect HTTP 400 and {"error":"Invalid CSV syntax or record too large."}. cURL exits with code 22
because of --fail-with-body; this rejection is the intended result. Run the GET /data command
again: Jane and Tim should still be there. Repeating the successful upload replaces the dataset
with the same two records, without appending duplicates.
Other useful checks are an empty file or header-only CSV, which returns 400, and a file larger than
5 MiB, which returns 413. A missing file part, a different field name, multiple files, or extra
text fields also returns 400. Errors caused by invalid CSV are client errors; an unexpected storage
failure returns 500.
In Postman, choose POST and http://127.0.0.1:3100/upload. Under Body → form-data, add a
single key named file, set its type to File, and select data.csv. Let Postman generate the
request’s Content-Type header, including its boundary. Stop the server with Ctrl+C when finished.
Production considerations
Before exposing this endpoint, add authentication and authorize both importing and reading each dataset. Replace the shared array with durable storage and use a transaction or staged import so a bad row cannot leave a partially updated dataset. Authentication alone does not isolate one user’s records from another’s.
Keep temporary uploads outside publicly served directories. The cleanup here handles ordinary request and parser failures; a process crash can leave files behind, so a deployed service also needs a policy for removing abandoned uploads. Apply request timeouts, rate limits, and concurrency limits: a per-file cap does not bound the combined memory and disk usage of many simultaneous requests. Larger imports usually need a background job and an import-status endpoint.
