Postgres by Example: INSERT
June 22, 2026 · View on GitHub
INSERT adds rows to a table. The basic shape is INSERT INTO table (columns) VALUES (values). You can insert one row, many rows in a single statement, or the result of a SELECT. Bulk insertion through a single INSERT ... VALUES (...), (...), ... is dramatically faster than thousands of separate statements because each statement otherwise pays a network round trip and a fresh planning cost.
What you'll learn:
- Single-row and multi-row
INSERT - Why naming columns explicitly is the right default
- Inserting from a query (
INSERT INTO ... SELECT) - Using
DEFAULTand omitting columns - Status messages: what
INSERT 0 5actually means
-- One row at a time (most explicit, often fine for app code)
INSERT INTO fruits (id, name) VALUES (1, 'apple');
INSERT INTO fruits (id, name) VALUES (2, 'banana');
INSERT INTO fruits (id, name) VALUES (3, 'cherry');
-- Many rows in one statement (much faster for bulk loads)
INSERT INTO fruits (id, name) VALUES
(4, 'date'),
(5, 'elderberry');
-- INSERT ... SELECT: copy rows from another query
-- (here a generator, to avoid duplicating fruits we just inserted)
INSERT INTO fruits (id, name)
SELECT n + 100, 'fruit_' || n
FROM generate_series(1, 3) AS n;
-- Confirm
SELECT count(*) AS row_count FROM fruits;
Run this script after create-table.sql — the table must exist. The first three statements each insert one row; the fourth inserts two; the fifth inserts three rows generated by generate_series. The status message INSERT 0 5 from psql means inserted, with old OID 0, 5 rows affected — OIDs are legacy and you can ignore the first number; the second is the count.
Why list the columns? Two reasons. First, if the table later gains a column, your INSERT INTO fruits VALUES (...) (no column list) will break, whereas INSERT INTO fruits (id, name) VALUES (...) keeps working. Second, listing columns makes the intent obvious to anyone reading the code — values are not positional puzzles.
For bulk loading from a file (CSV, etc.), prefer COPY (covered later) — it is significantly faster than INSERT. Application code should batch rows into multi-row INSERT statements or use a driver-level prepared statement.
To run:
$ psql -f source/insert.sql postgres
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 2
INSERT 0 3
row_count
-----------
8
(1 row)
Common pitfalls:
- Re-running the script when rows already exist causes primary key or unique violations (once you add a PK). The remedy depends on intent: drop and recreate the table, use
TRUNCATE, orINSERT ... ON CONFLICTto upsert (next lesson). - Wrong column order in a positional
INSERTquietly corrupts data — the database happily inserts the wrong value into the wrong column if the types are compatible. Always list columns. - Inserting timestamps with the wrong timezone is the usual cause of "the time is off by 8 hours" in production. Use
timestamptzand let PostgreSQL handle the conversion.
Tip: INSERT INTO t DEFAULT VALUES; inserts a row using only column defaults — useful when every column has a DEFAULT (e.g. an id from a sequence and created_at from now()).
Try it: Add another row with (6, 'fig') and run the file again. (It will fail once id is unique — we will fix that in the upsert lesson.) Then try INSERT INTO fruits (id, name) SELECT n, 'gen_' || n FROM generate_series(200, 205) n; to see a query-driven insert.
Source: insert.sql
Next: SELECT from a Table
Home: Postgres by Example