mygemsSay hi

How to run SQL queries on a CSV file without a database (2026 guide)

You do not need Postgres to run a GROUP BY. Here is how to query a CSV in place with DuckDB, SQLite, q or your phone — and which one fits which situation.

By uos ·

The short version: point DuckDB at the file.

duckdb -c "SELECT region, SUM(amount) FROM 'sales.csv' GROUP BY region"

No schema, no import, no server, no CREATE TABLE. The CSV stays exactly where it is, and the header row becomes the column names. If you install one thing today, install that one.

The rest of this page is why that works, what to do when DuckDB isn't available to you, and how to handle the two things that trip everyone up — quoting a filename, and columns whose names have spaces in them.

Why you don't need to load the file anywhere

The instinct to "get the data into a database first" is a holdover from when SQL engines only knew how to read their own storage. Modern ones don't have that limitation. DuckDB, SQLite and several small CLI tools will treat a CSV as a table for the length of one query, infer the types by sampling it, answer, and forget the whole thing.

That changes the calculation completely. A one-off question about a file you were emailed is no longer worth a schema, a load script and a cleanup step. It's worth one line.

Loading into a real database is still the right call in exactly two cases: the data is going to be queried repeatedly by more than one person, or it needs constraints and relationships that a single flat file can't express. Neither describes "what's the total by region in this export".

The options, honestly

ToolInstallBest forWeak at
DuckDBOne binary, no depsEverything up to tens of GBNothing much at this job
SQLite + .importAlready on macOS and LinuxPersisting the table for laterTypes — everything lands as TEXT
qpip install q-text-as-dataQuick one-liners in a shell pipelineLarge files; it is slower
csvsql (csvkit)pip install csvkitGenerating a CREATE TABLE for a real DBSpeed — it is the slowest here
Pandas + pandasqlYou already have pandasWhen the answer feeds more PythonThe SQL dialect is SQLite's, not Postgres's
Phone app with SQLApp Store / PlayThe file arrived in your email, on your phoneAnything you'd want to script

The honest ranking is short: DuckDB for almost everything, SQLite when you can't install anything, and a phone app when you aren't at a computer. The others are fine and have their moments, but nothing on that list beats DuckDB at this specific job.

DuckDB, properly

Install is a single binary — brew install duckdb, or download it and put it on your path. Then:

duckdb
D SELECT * FROM 'orders.csv' LIMIT 10;

Things worth knowing straight away:

The filename needs single quotes. FROM orders.csv is parsed as a schema named orders and a table named csv, and the error message doesn't make that obvious. FROM 'orders.csv' is right.

Column names with spaces need double quotes. SELECT "Order Date" FROM …. Single quotes there give you the literal string, silently, for every row — which looks like the query worked.

Glob a whole directory when the export came in monthly pieces:

SELECT * FROM 'exports/*.csv'

When type inference gets it wrong — usually a column of IDs with leading zeros, or dates in a format it doesn't recognise — take control of the read instead of fighting it:

SELECT * FROM read_csv('orders.csv',
  header = true,
  columns = {'id': 'VARCHAR', 'ordered_at': 'DATE', 'amount': 'DOUBLE'},
  dateformat = '%d/%m/%Y'
)

read_csv also takes delim, quote, escape, ignore_errors = true and sample_size = -1 (scan the whole file before deciding types, rather than the first 20,480 rows). That last one is the fix when a column looks numeric for ten thousand rows and then hits N/A.

Write the answer back out without leaving SQL:

COPY (SELECT * FROM 'orders.csv' WHERE amount > 1000)
TO 'large-orders.csv' (HEADER, DELIMITER ',');

And if the file is going to be queried more than a handful of times, convert it once — COPY (SELECT * FROM 'orders.csv') TO 'orders.parquet' — and query the Parquet instead. It is typed, compressed, and usually five to twenty times faster to scan.

SQLite, when you can't install anything

sqlite3 ships with macOS and most Linux distributions, so this is the option that works on a locked-down machine.

sqlite3 shop.db
sqlite> .mode csv
sqlite> .import orders.csv orders
sqlite> SELECT region, COUNT(*) FROM orders GROUP BY region;

The catch, and it matters: .import makes every column TEXT. Sorting is then lexicographic, so 100 sorts before 20, and SUM() gives surprises. Cast at query time (CAST(amount AS REAL)), or create the table with real types before importing into it.

Unlike DuckDB, this leaves a shop.db behind — which is the point if you want the table again tomorrow, and clutter if you didn't.

q, for a shell pipeline

When the query is one step in a chain of shell commands, q reads standard input like any other Unix tool:

q -H -d, "SELECT region, SUM(amount) FROM - GROUP BY region" < orders.csv

-H means the first row is a header, -d, sets the delimiter, and - is stdin. It's SQLite underneath, so the dialect is SQLite's. It's also the slowest sensible option on a big file — fine for a few hundred thousand rows, painful past that.

When the file is on your phone

None of the above helps when the export is an attachment in your email and you are not at a desk. That is a real situation and the usual answer — "send it to yourself and look at it later" — is a bad one.

CSV File Viewer - Smart CSViPhone & iPad · Android

Runs actual SQL against the file on iOS and Android, with a visual filter builder for when you'd rather not type a query on a phone keyboard, and a plain-language assistant for when you don't know the column names yet.

What it does

The dialect is SQLite's, so the queries in this article port across with the usual caveats — no window functions from the DuckDB examples, and the same CAST habits apply.

Queries worth having ready

What is actually in this file:

SELECT * FROM 'file.csv' LIMIT 20;
DESCRIBE SELECT * FROM 'file.csv';
SELECT COUNT(*) FROM 'file.csv';

Where the nulls are, one column at a time being tedious, so:

SELECT COUNT(*) - COUNT(email) AS missing_email,
       COUNT(*) - COUNT(phone) AS missing_phone
FROM 'contacts.csv';

Duplicates on a key:

SELECT id, COUNT(*) AS n
FROM 'orders.csv'
GROUP BY id HAVING n > 1
ORDER BY n DESC;

Joining two exports — the query that most often justifies the whole exercise, because doing it with VLOOKUP across two sheets is genuinely worse:

SELECT o.id, o.amount, c.name
FROM 'orders.csv' o
JOIN 'customers.csv' c ON c.id = o.customer_id;

A monthly total from a date column:

SELECT date_trunc('month', ordered_at) AS month, SUM(amount)
FROM 'orders.csv'
GROUP BY month ORDER BY month;

The failure modes

Everything came back as one column. The delimiter isn't a comma — semicolons are standard in much of Europe. read_csv('f.csv', delim = ';'). If the file is stranger than that, it may be genuinely malformed rather than merely unusual.

A numeric column won't sum. It was inferred as text, because something non-numeric appears in it. Find the culprit with SELECT amount FROM 'f.csv' WHERE TRY_CAST(amount AS DOUBLE) IS NULL LIMIT 10; — it's usually a currency symbol, a thousands separator, or the string N/A.

Leading zeros vanished. The column was read as a number. Declare it VARCHAR in columns, and don't let a spreadsheet anywhere near it afterwards.

The header row is repeated throughout the file. Several exports were concatenated. WHERE id <> 'id' is the crude fix and usually the correct one.

It ran out of memory. DuckDB streams, so this normally means an ORDER BY or a DISTINCT over the whole file. Give it a temp directory (SET temp_directory = '/tmp/duckdb') and it will spill to disk instead — or see how to open a 1GB CSV file for the wider question of what handles what at scale.

Questions

Can you run SQL on a CSV file without importing it?
Yes. DuckDB queries a CSV in place — `SELECT * FROM 'file.csv'` — inferring column names from the header row and types by sampling. Nothing is imported, no table is created, and the file is unchanged. SQLite can do it too, but its .import step does create a persistent table.
What is the fastest way to query a large CSV with SQL?
DuckDB, by a wide margin among the no-setup options. It reads the file in parallel and streams rather than loading everything into memory, so it handles files far larger than RAM. If you will query the same file repeatedly, convert it to Parquet once with COPY … TO 'file.parquet' — scans then run five to twenty times faster.
How do I query a CSV column whose name has a space in it?
Wrap it in double quotes: SELECT "Order Date" FROM 'orders.csv'. Single quotes produce a string literal instead, which returns the same text for every row rather than an error — so the query looks like it worked.
Can I run SQL queries on a CSV file on my phone?
Yes. Smart CSV Viewer runs SQL against a CSV on iOS and Android, with a visual filter builder as an alternative to typing queries on a phone keyboard. The engine is SQLite, so the dialect is SQLite's rather than DuckDB's.
Why does my numeric column refuse to sum?
It was inferred as text because something non-numeric appears somewhere in it — a currency symbol, a thousands separator, or a placeholder like N/A. Find the offending rows with TRY_CAST(col AS DOUBLE) IS NULL, then either clean them or read the column explicitly with read_csv's `columns` argument.
When should I load a CSV into a real database instead?
Two cases: the data will be queried repeatedly by more than one person, or it needs constraints and relationships a single flat file cannot express. A one-off question about a file someone emailed you is not worth a schema and a load script.

uos Builds Smart CSV Viewer, which puts a SQL engine on a phone — so the question of what is worth loading into a database comes up often.