How to Analyze Excel and CSV Files with SQL
Finance sends you an .xlsx with Q2 actuals. Marketing exports a CSV from HubSpot. The vendor emails a pipe-delimited TSV of inventory counts. You open each one in a separate spreadsheet tab, squint at column headers that don't quite match, and start VLOOKUPing your way through a Thursday afternoon.
There's a better way. DuckDB reads Excel and CSV files as if they were database tables. You write SQL. No import step, no schema definitions, no loading anything into a warehouse.
I switched to this workflow about a year ago and I don't miss pivot tables.
Query a CSV file directly
SELECT
region,
product_line,
SUM(revenue) AS total_revenue,
COUNT(DISTINCT customer_id) AS unique_customers
FROM read_csv('sales_export.csv')
WHERE revenue > 0
AND order_date >= '2026-01-01'
GROUP BY region, product_line
ORDER BY total_revenue DESC;That's it. read_csv auto-detects delimiters, column names, and types by sampling the first rows. Point it at a file, get a table back.
A few things worth knowing. DuckDB's type sniffer samples 20,480 rows by default. If your CSV has a column that's blank for the first 20,000 rows and then starts containing dates, you'll get VARCHAR. Fix it by casting explicitly or passing sample_size = -1 to scan the whole file for type detection. Slower on large files, but it eliminates surprises.
Tab-delimited files work the same way — DuckDB detects the delimiter automatically. If auto-detection fails (rare, but it happens with mixed delimiters), pass delim = '\t' explicitly.
Query an Excel file
SELECT
employee_id,
department,
hire_date,
salary
FROM read_xlsx('headcount_q2.xlsx', sheet = 'Active')
WHERE department = 'Engineering'
ORDER BY hire_date;read_xlsx reads .xlsx files natively. Specify the sheet by name or index (sheet = 1 for the first sheet). If you omit sheet, DuckDB reads the first one.
Excel date handling is the one gotcha that'll bite you. Excel stores dates as serial numbers — days since January 1, 1900 (with a famous Lotus 1-2-3 leap year bug baked in). DuckDB handles the conversion automatically in most cases, but if your dates show up as integers like 44927, you're looking at raw serial values. This happens when the Excel column isn't formatted as a date type. Fix it with DATE '1899-12-30' + INTERVAL (hire_date) DAY. The offset is 1899-12-30, not 1900-01-01, because of that Lotus bug. It's been 40 years and we're all still working around it.
Fastero
Connect your database. Ask questions. Get dashboards.
Postgres, BigQuery, Snowflake, and 10+ sources — live-connected, AI-powered, no dashboard builder learning curve.
Try free →Join multiple files together
This is where SQL beats spreadsheets decisively. You have three files from three different systems. In Excel, that's three tabs and a web of VLOOKUPs that break when someone inserts a column. In SQL:
SELECT
o.order_id,
o.customer_id,
o.order_total,
c.company_name,
c.segment,
i.sku,
i.quantity,
i.unit_cost * i.quantity AS line_cost
FROM read_csv('orders_june.csv') AS o
JOIN read_csv('customers.csv') AS c
ON o.customer_id = c.customer_id
JOIN read_xlsx('inventory_costs.xlsx', sheet = 'Unit Costs') AS i
ON o.sku = i.sku
WHERE o.order_date >= '2026-06-01'
AND c.segment = 'Enterprise';Three different file formats. One query. The join keys don't care whether the data came from a CSV, an Excel sheet, or a database. And if you need to run this again next month with updated files, you swap the filenames and re-run. No manual reconciliation.
Window functions work too — something that's painful or impossible in a spreadsheet:
SELECT
customer_id,
order_date,
order_total,
SUM(order_total) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS order_sequence
FROM read_csv('orders_june.csv')
ORDER BY customer_id, order_date;Running totals per customer, order sequencing — the kind of analysis that requires helper columns and circular reference warnings in Excel. In SQL it's a window clause.
Combine file data with database tables
The real power shows up when you join uploaded files against live database tables. Finance gives you a spreadsheet of budget targets. You want to compare those against actual revenue sitting in Postgres.
ATTACH 'dbname=analytics user=readonly host=db.internal' AS pg (TYPE POSTGRES);
SELECT
b.department,
b.quarter,
b.budget_target,
COALESCE(a.actual_revenue, 0) AS actual_revenue,
COALESCE(a.actual_revenue, 0) - b.budget_target AS variance,
ROUND(COALESCE(a.actual_revenue, 0) / b.budget_target * 100, 1) AS pct_of_target
FROM read_xlsx('budget_targets_2026.xlsx') AS b
LEFT JOIN (
SELECT
department,
'Q2' AS quarter,
SUM(amount) AS actual_revenue
FROM pg.public.revenue
WHERE created_at BETWEEN '2026-04-01' AND '2026-06-30'
GROUP BY department
) AS a
ON b.department = a.department
AND b.quarter = a.quarter
ORDER BY variance;Budget-to-actuals in a single query. The Excel file stays on your disk, the revenue data stays in Postgres, and DuckDB joins them in memory. No ETL, no warehouse, no waiting for a nightly sync.
This pattern — file plus database — covers an enormous number of ad-hoc analysis requests. Territory assignments from a spreadsheet joined against CRM data. Vendor price lists joined against purchase orders. Bonus targets joined against sales actuals. Every one of these used to require a Python script or a painful spreadsheet merge.
Why SQL over pivot tables
Pivot tables are fine for single-file, single-dimension summaries. Once you need any of the following, they fall apart:
- Joins across files. VLOOKUPs break when columns shift. SQL joins on named keys.
- Reproducibility. A pivot table is a click sequence. SQL is a text file you can version, review, and re-run.
- Complex conditions. Filtering on "orders where the customer signed up in the last 90 days and has placed more than 3 orders and their most recent order was over $500" is one WHERE clause in SQL. In a spreadsheet, it's nested IFs and helper columns.
- Window functions. Running totals, rankings, percentiles, moving averages — SQL has dedicated syntax for these. Spreadsheets fake it with array formulas.
The gap widens as the data grows. A 500-row CSV? Use whatever you want. A 500,000-row CSV joined against a 2-million-row export? That's where DuckDB finishes in seconds and Excel freezes for two minutes (if it doesn't crash).
How Fastero handles this
Fastero runs DuckDB as its analytical store. Upload an Excel file, a CSV, or a PDF with tables — Fastero extracts the data and makes it queryable with SQL immediately. No local DuckDB install, no file path management, no connection strings.
You get the SQL workflow described in this post without the setup. Upload files from different sources, join them together, connect a live database, and query across everything in one place. The AI agent can also write the SQL for you — describe what you want in plain English and it generates the query against your actual schema.
If you're doing this kind of file-based analysis regularly, it's worth trying. The pattern of "get a file, write SQL, get answers" is fast once DuckDB is involved. Fastero just removes the infrastructure part — the DuckDB engine runs in the cloud, your files are already loaded, and you can profile any new dataset in minutes.
For a deeper comparison of DuckDB against Python-based alternatives, see DuckDB vs Pandas.
Try Fastero free — upload Excel and CSV files, query them with SQL instantly. No credit card required.

