Bulk Loading Data in PostgreSQL with COPY
Learn PostgreSQL's COPY command for fast bulk loading and exporting of CSV data, why it's dramatically faster than individual INSERT statements, and \copy in psql.
Bulk Loading with COPY
Inserting a large dataset (thousands to millions of rows) with individual INSERT statements is slow — each one pays the overhead of a full SQL round trip. COPY streams data in bulk instead, and is dramatically faster.
COPY FROM — Loading Data In
COPY employees (name, email, department, salary)
FROM '/path/to/employees.csv'
DELIMITER ','
CSV HEADER;
CSV HEADERtells Postgres the first line is column names, not data — skip it, don't insert it.- The file path must be readable by the PostgreSQL server process, not just your own user — which is often inconvenient for remote/managed databases.
\copy — The psql Client-Side Alternative
\copy (with a backslash, run from psql) reads the file from your own machine instead of the server's filesystem — this is what you'll actually use most of the time when connecting to a remote or managed database.
psql -d shop -c "\copy employees(name, email, department, salary) FROM 'employees.csv' CSV HEADER"
COPY TO — Exporting Data Out
COPY (SELECT * FROM employees WHERE department = 'Engineering')
TO '/path/to/engineering.csv'
CSV HEADER;
psql -d shop -c "\copy (SELECT * FROM employees) TO 'all_employees.csv' CSV HEADER"
Why COPY Is So Much Faster
- A single
COPYstatement is one operation for the whole dataset, instead of one round trip per row. - It skips much of the per-statement parsing/planning overhead that individual
INSERTs incur. - It's the standard tool for ETL jobs, data migrations, and loading exports from other systems.
A Realistic Bulk-Load Pattern
For very large loads, it's common to load into an unconstrained staging table first, then insert the cleaned data into the real table:
CREATE TABLE staging_employees (LIKE employees INCLUDING DEFAULTS);
\copy staging_employees FROM 'employees_export.csv' CSV HEADER
INSERT INTO employees
SELECT * FROM staging_employees
WHERE email IS NOT NULL
ON CONFLICT (email) DO NOTHING;
This lets you validate/dedupe in SQL before the data ever reaches the real, constrained table.
Common Errors & Troubleshooting
ERROR: could not open file for reading: Permission denied— plainCOPYruns on the server, which needs its own filesystem permission to read that path; use\copy(client-side, inpsql) instead if the file is only on your machine.ERROR: extra data after last expected column— the CSV has more (or fewer) columns than the target table/column list — double-check the delimiter matches (comma vs. semicolon vs. tab) and that no field contains an unescaped comma.- Duplicate key errors partway through a large
COPY— the wholeCOPYis one transaction by default, so a single bad row aborts the entire load; load into an unconstrained staging table first (as shown above), then filter/dedupe before inserting into the real, constrained table.
Key Takeaways
COPYis orders of magnitude faster than row-by-rowINSERTfor large datasets — always prefer it for bulk loads.\copy(client-side, inpsql) reads from your own machine; plainCOPY(server-side, in SQL) requires the file to be on the database server itself.- Loading into an unconstrained staging table first, then
INSERT ... SELECTinto the real table, is the standard pattern for messy bulk data.