How to Import CSV and Excel Data into PostgreSQL
Practice4 minInnovialabs editorial team
In short
Load the file into a staging table with text columns first using COPY, \copy, DBeaver or Python, then cast types, remove duplicates and move only validated rows into the real table.
The short answer
One reliable workflow works with any tool:
- Load the file as-is into a staging table where every column is
text. - Transform types, dates and formats with SQL.
- Move the rows into the real table, filtering out duplicates.
- Validate: row counts, empty values, totals.
That way one broken line doesn't abort the whole import, and bad data never reaches production tables.
Which tool to use
| Method | Good for |
|---|---|
COPY | The file is already on the database server; speed and large volumes |
\copy in psql | The file is on your machine and the database is remote |
| DBeaver | A one-off load without a command line, with visual column mapping |
| Python (pandas) | Excel with several sheets, complex cleanup, scheduled imports |
Prepare a staging table
CREATE TABLE staging_clients (
name text,
phone text,
city text,
created text
);
Text columns accept anything — "12.03.2026", "n/a", stray spaces — and SQL is the right place to deal with it.
COPY and \copy
COPY reads a file on the PostgreSQL server. It needs superuser rights or the pg_read_server_files role.
COPY staging_clients (name, phone, city, created)
FROM '/var/lib/postgresql/import/clients.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ';', ENCODING 'WIN1251');
\copy is a psql client command: the file is read on your machine and streamed to the server. It must be written on one line:
\copy staging_clients (name, phone, city, created) FROM 'clients.csv' WITH (FORMAT csv, HEADER true, DELIMITER ';', ENCODING 'WIN1251')
Key options:
- DELIMITER — Excel with many European and CIS locales saves CSV with semicolons rather than commas.
- ENCODING — older exports from Excel or 1C are often
WIN1251. Garbled Cyrillic means the encoding is wrong. Saving as "CSV UTF-8" avoids the issue. - HEADER — skip the header row.
DBeaver
Handy for a one-off load without a console:
- Right-click the staging table → Import Data → CSV.
- Set the encoding, delimiter and date format in the import settings.
- Check the mapping between file columns and table columns.
- Run the import and review the error log.
Save Excel files as CSV first, or use Python.
Python and Excel
pandas reads .xlsx sheets directly (it needs the openpyxl package):
import pandas as pd
from sqlalchemy import create_engine
df = pd.read_excel("clients.xlsx", sheet_name="Clients", dtype=str)
df.columns = ["name", "phone", "city", "created"]
engine = create_engine("postgresql+psycopg://user:password@localhost:5432/shop")
df.to_sql("staging_clients", engine, if_exists="append", index=False, chunksize=1000)
dtype=str matters: without it, phone numbers turn into numbers, lose leading zeros and long values may end up in scientific notation. Keep the database password in environment variables, not in the code.
Dates, formats and duplicates
Transform and move the data in one statement:
INSERT INTO clients (name, phone, city, created_at)
SELECT DISTINCT ON (phone_clean)
trim(name),
phone_clean,
nullif(trim(city), ''),
to_date(created, 'DD.MM.YYYY')
FROM (
SELECT *, regexp_replace(phone, '\D', '', 'g') AS phone_clean
FROM staging_clients
) s
WHERE phone_clean <> ''
ORDER BY phone_clean
ON CONFLICT (phone) DO NOTHING;
What happens here:
to_datewith an explicit format prevents mixing up day and month;- phones are reduced to digits, so "+998 90 123-45-67" and "998901234567" count as one number;
DISTINCT ONremoves duplicates inside the file,ON CONFLICTskips rows that already exist (this needs a unique index onphone).
Validate after the import
- Compare row counts in the file, staging and the target table. Any difference should be explained by duplicates and rejected rows.
- Count NULLs in required fields.
- Check totals and ranges: no dates in the future, no negative amounts.
- Eyeball a few random rows.
Once everything checks out, clear staging with TRUNCATE.