Skip to content

How to convert TSV to CSV

Al Brown
Last updated: Jun 8, 2026

To convert a TSV file to CSV, use clickhouse local. It runs SQL directly on files from the command line, with no server to install. It's part of ClickHouse, so the same query scales to billions of rows when you outgrow your laptop.

Install it with clickhousectl:

curl https://clickhouse.com/cli | sh   # install clickhousectl
clickhousectl local use latest         # download ClickHouse and put it on your PATH

Then write the result out with the CSV format:

clickhouse local -q "SELECT * FROM file('events.tsv') INTO OUTFILE 'events.csv' TRUNCATE FORMAT CSVWithNames"
"event_date","event_id","country","action","amount","qty"
"2026-01-01",1,"GB","click",5,1
"2026-01-02",2,"US","view",6.01,2
"2026-01-03",3,"DE","signup",7.02,3
"2026-01-04",4,"FR","purchase",8.03,4
"2026-01-05",5,"IN","click",9.04,5

file('events.tsv') is read in place with no import step: ClickHouse picks TSVWithNames from the extension, takes column names from the first row, and infers types from the data. INTO OUTFILE ... FORMAT CSVWithNames writes those same rows back as comma-separated values with a header, quoting string fields and leaving numbers bare.

The types survive the round trip

This is the difference between a real conversion and a find-and-replace of tabs with commas. ClickHouse parses the TSV into typed columns, so it knows amount is a float and event_date is a date. DESCRIBE the output file to see the schema it carried across:

clickhouse local -q "DESCRIBE file('events.csv')"
event_date	Nullable(Date)
event_id	Nullable(Int64)
country	Nullable(String)
action	Nullable(String)
amount	Nullable(Float64)
qty	Nullable(Int64)

A textual tab-to-comma swap cannot do this, and it breaks the moment a field contains a comma: that field needs quoting in CSV, and ClickHouse quotes it for you. Both formats are described in what is a TSV file.

Options that matter

This is the information you don't get from an online "TSV to CSV" converter, which uploads your file, gives you one fixed output, and caps the size.

CSVWithNames writes the header row. If the consumer expects a bare CSV with no header, use the plain CSV format instead:

clickhouse local -q "SELECT * FROM file('events.tsv') INTO OUTFILE 'events_noheader.csv' TRUNCATE FORMAT CSV"
"2026-01-01",1,"GB","click",5,1
"2026-01-02",2,"US","view",6.01,2
"2026-01-03",3,"DE","signup",7.02,3

If your input TSV has no header, read it with the plain TSV format and pass a schema, for example file('events.tsv', 'TSV', 'event_date Date, ...').

Because the source is a SQL table, you can also select, rename, cast, or filter columns in the same command instead of doing a second pass:

clickhouse local -q "
SELECT
  event_date,
  upper(country)        AS country_code,
  amount::Decimal(10,2) AS amount
FROM file('events.tsv')
INTO OUTFILE 'events_clean.csv' TRUNCATE FORMAT CSVWithNames"
"event_date","country_code","amount"
"2026-01-01","GB",5
"2026-01-02","US",6.01
"2026-01-03","DE",7.02

For a semicolon rather than a comma at the destination, set format_csv_delimiter and keep everything else the same: ... FORMAT CSVWithNames SETTINGS format_csv_delimiter=';'.

One thing to know: this is a row-for-row conversion of flat columns. If your TSV holds a column of raw JSON or another nested structure, it stays as a string in the CSV. CSV has no nested types, so there is nothing lost beyond what CSV itself can't represent.

Did anything get dropped?

Count both files. They match, which is the quick check that the conversion was clean:

clickhouse local -q "SELECT count() FROM file('events.tsv')"   # 20
clickhouse local -q "SELECT count() FROM file('events.csv')"   # 20

Reverse direction

Going the other way is the same command with the formats swapped. See convert CSV to TSV. If you'll query the data repeatedly rather than hand it off, skip CSV and go to a columnar format with convert TSV to Parquet instead — it's smaller and far faster to scan.

How fast is it?

On a 3,000,000-row, ~106 MB tab-separated file (events_large.tsv), the full TSV-to-CSV conversion runs in:

clickhouse local -q "SELECT * FROM file('events_large.tsv') INTO OUTFILE 'events_large.csv' TRUNCATE FORMAT CSVWithNames"

~0.23 seconds, best of three with a warm OS page cache, on an Apple M4 Pro laptop (14 cores, 24 GB RAM). That parses every row and re-serialises it; there is no precomputed table. Because it streams, the file does not have to fit in memory, so the same command works on a multi-gigabyte TSV that an upload-based converter would reject.

Convert in Python with chDB

If you're already in Python, chDB is the same ClickHouse engine in-process. The conversion is the identical SQL:

import chdb

chdb.query(
    "SELECT * FROM file('events.tsv') "
    "INTO OUTFILE 'events_chdb.csv' TRUNCATE FORMAT CSVWithNames"
)
"event_date","event_id","country","action","amount","qty"
"2026-01-01",1,"GB","click",5,1
"2026-01-02",2,"US","view",6.01,2
"2026-01-03",3,"DE","signup",7.02,3

Install with pip install chdb. No server, no separate ClickHouse install.

Run it yourself

The complete, runnable example lives in the ClickHouse examples repo: generate.sh to create the sample TSVs (including the ~106 MB file used for the timing), run.sh with every command on this page, run.py / run.ipynb for the chDB version, and expected_output.txt.

github.com/ClickHouse/examples/tree/main/local-analytics/convert-tsv-to-csv

clickhouse-local runs the exact same SQL unchanged across dozens of formats and remote sources, and against a ClickHouse server or ClickHouse Cloud when the data outgrows your machine. Related: query a TSV file with SQL and run SQL on a CSV file.


Share this resource

  • Y Combinator icon
  • X icon
  • Bluesky icon
  • Facebook icon
  • LinkedIn icon

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!

More like this

AlloyDB alternatives compared for 2026: Aurora, RDS, Neon, Crunchy Bridge, and ClickHouse Managed Postgres with benchmarks, tradeoffs, and decision criteria.

Continue reading ->

ClickHouse concurrency: how to size for user-facing analytics

Manveer Chawla • Last updated: Jul 2, 2026

How to size ClickHouse for high-concurrency, user-facing analytics: turn active users into query load, benchmark under production-like conditions, and configure per-query limits, admission controls, workload scheduling, and replicas.

Continue reading ->

When to denormalize, when to join: A ClickHouse guide

Manveer Chawla • Last updated: Jun 26, 2026

Denormalization and normalization are both valid analytical data-modeling strategies. A decision framework for choosing where to denormalize, where to join, and which ClickHouse primitives bridge the gap.

Continue reading ->