INSERT INTO
Add rows to tables — single inserts, bulk inserts, insert from SELECT, upsert patterns, and every safety practice for writing data correctly
Quick answer
INSERT INTO adds rows to a table: name the columns, then give the values in the same order. One statement can insert many rows, or insert the result of a SELECT.
- Always list the columns: INSERT INTO t (a, b) VALUES (1, 2).
- A multi-row VALUES list is much faster than one INSERT per row.
- INSERT … ON CONFLICT DO UPDATE inserts new rows and updates existing ones (upsert).
INSERT INTO customers (customer_id, first_name, last_name, city, loyalty_tier)
VALUES (21, 'Ava', 'Martinez', 'Denver', 'Bronze');
SELECT customer_id, first_name, city, loyalty_tier
FROM customers
WHERE customer_id = 21;Result · 1 row
| customer_id | first_name | city | loyalty_tier |
|---|---|---|---|
| 21 | Ava | Denver | Bronze |
// Part 01
Writing Data Into a Table
Every query you have written so far has been read-only — SELECT returns data but never changes it. INSERT INTO is where you start writing data. It is the command that adds new rows to a table and the foundation of every application that stores anything.
INSERT runs inside applications thousands of times per second at scale. When a FreshCart customer places an order, an INSERT adds a row to the orders table and multiple rows to order_items. When a new user registers, an INSERT adds their record to customers. When stock is replenished, an INSERT or UPDATE reflects the new quantity. Understanding INSERT deeply — the syntax, the safety rules, the performance patterns, and the failure modes — is essential for any SQL developer.
// Part 02
Basic Syntax — Single Row Insert
The fundamental INSERT statement specifies the table name, the list of columns to populate, and the values to insert. Always list column names explicitly — never rely on positional column order.
Why always list column names
SQL allows omitting the column list: INSERT INTO customers VALUES (val1, val2, ...). This works but is extremely dangerous. It requires values in the exact order columns were defined in CREATE TABLE — an order that is invisible to anyone reading the query. If a column is added, removed, or reordered in the future, the positional INSERT silently inserts wrong values into wrong columns with no error. Always name your columns.
Columns with defaults and nullable columns
You do not need to specify every column in INSERT. Columns omitted from the INSERT list receive their DEFAULT value if one is defined, or NULL if nullable with no default. Columns with NOT NULL and no default must be included.
// Part 03
Multi-Row INSERT — Adding Many Rows at Once
Instead of running one INSERT per row, SQL lets you insert multiple rows in a single statement by listing multiple VALUES tuples separated by commas. This is dramatically faster than individual inserts — one round-trip to the database instead of N, and one transaction instead of N.
Performance: multi-row vs single-row inserts
Inserting 1,000 rows as a single multi-row INSERT is 10–100× faster than 1,000 individual INSERT statements. Each individual INSERT requires: a network round-trip, query parsing, constraint checking, index updating, and a transaction commit. A single multi-row INSERT does all of this once for all rows. For bulk data loading, use multi-row inserts or COPY (PostgreSQL) / LOAD DATA INFILE (MySQL) for even larger volumes.
🎯 Pro Tip
In application code that needs to insert many rows — user registrations from a batch import, order items for a cart checkout, events from a stream — always batch inserts rather than inserting one row per database call. Most ORMs and database drivers support batch insert. In Python with psycopg2: executemany() or execute_values(). In Node.js: use a parameterised multi-row INSERT. Batching 100-1000 rows per INSERT is a standard production optimisation.
// Part 04
INSERT INTO ... SELECT — Copy Data Between Tables
Instead of hardcoded VALUES, you can use a SELECT query as the source of rows to insert. This is one of the most powerful INSERT patterns — it lets you copy, transform, or aggregate data from one table into another in a single statement.
Copy rows between tables
Archive pattern — move old rows to a history table
Transform and load pattern
// Part 05
RETURNING — Get Back What You Just Inserted
In PostgreSQL (and DuckDB), the RETURNING clause lets you retrieve column values from the rows you just inserted — without needing a separate SELECT. This is essential for getting the auto-generated primary key after an INSERT.
// Part 06
ON CONFLICT — Upsert Patterns
An upsert (update-or-insert) handles the case where you want to insert a row if it does not exist, or update it if it does. This is one of the most common patterns in production systems — syncing data from an external source, updating a counter, or applying a daily feed of records that may or may not already exist.
PostgreSQL — ON CONFLICT DO NOTHING
PostgreSQL — ON CONFLICT DO UPDATE (true upsert)
MySQL — INSERT ... ON DUPLICATE KEY UPDATE
// Part 07
INSERT Safety — Preventing Data Corruption
INSERT is a write operation — it permanently changes the database. Getting it wrong in production can corrupt data, violate constraints, or create orphan records. These safety practices should be second nature before you run any INSERT on a real database.
Always wrap related INSERTs in a transaction
A transaction groups multiple statements so they either all succeed or all fail together. When inserting an order and its items, both must succeed — a partial insert (order without items, or items without an order) leaves the database in an inconsistent state.
Use parameterised queries — never string interpolation
In application code, never build INSERT statements by concatenating user input into a SQL string. This is the source of SQL injection attacks — one of the most critical security vulnerabilities in web applications.
Verify before bulk insert — check the SELECT first
Before running INSERT INTO ... SELECT, always run the SELECT alone and inspect the results. Confirm the row count, spot-check values, and verify the data looks correct before committing it to the target table.
// Part 08
INSERT with DEFAULT and CURRENT Values
INSERT statements can use SQL functions and expressions as values — not just static literals. This is used extensively for timestamps, computed values, and defaults.
// Part 09
Bulk Loading — COPY and Large Data Imports
For large-scale data loading — millions of rows from CSV files, data migrations, ETL pipelines — INSERT statements one by one are too slow. Production databases provide specialised bulk load commands that bypass normal row-by-row processing and load data directly at the storage layer.
PostgreSQL COPY — the fastest bulk loader
MySQL LOAD DATA INFILE
When to use COPY vs INSERT
· Application-level inserts (user actions, API calls)
· Small batches (1–1000 rows)
· Rows that need constraint checking per-row
· Upsert logic (ON CONFLICT)
· When you need RETURNING for generated IDs
· Bulk data loads (10,000+ rows)
· CSV file imports
· Data migrations between environments
· ETL pipeline data loading
· Initial database seeding with large datasets
// Part 10
What This Looks Like at Work
You are a backend engineer at Instacart, a 10-minute grocery delivery startup. It is 8 PM on a Friday. A new batch of 500 stores has been approved for onboarding — their data is in a staging table from the operations team's form submissions. You need to load them into production safely before the weekend.
🎯 Pro Tip
The verify-before-insert pattern is not optional for production bulk loads. Always: (1) inspect the staging data for NULLs in required fields, (2) check for internal duplicates within the staging data, (3) check for conflicts with existing production data, (4) run the INSERT inside a transaction, (5) verify the inserted count matches the expected count. Finding a data quality issue before the INSERT costs you 10 minutes. Finding it after the INSERT costs you hours of cleanup.
// Part 11
Interview Prep — 5 Questions With Complete Answers
INSERT with VALUES inserts explicitly specified data — the values are hardcoded in the statement. INSERT INTO ... SELECT inserts the results of a query — the data comes from existing tables, can be transformed by the SELECT, and is dynamic. Both insert rows into the same target table using the same constraints and triggers.
VALUES is used in application code for user-generated data: a new customer registration, a new order, a single product addition. The values come from application logic or user input. INSERT INTO ... SELECT is used for data pipeline operations: copying a subset of rows from one table to another, archiving old records to a history table, populating a summary table from raw data, or denormalising relational data into a flat reporting structure.
A critical safety difference: INSERT INTO ... SELECT can insert zero rows (if the SELECT returns nothing) or millions of rows (if the SELECT is broad) without any error — the statement succeeds either way. Always verify the SELECT result count before running INSERT INTO ... SELECT in production. Run the SELECT alone first to confirm the data and row count are what you expect. VALUES inserts a fixed known number of rows — if the VALUES list has 5 tuples, exactly 5 rows are attempted (subject to constraint violations).
An upsert is an operation that inserts a row if it does not exist, or updates it if it does — combining INSERT and UPDATE into a single atomic operation. The name comes from combining "update" and "insert." Upserts are essential in systems that synchronise data from external sources, apply daily data feeds, or maintain running counters — where you cannot know in advance whether the row already exists.
In PostgreSQL, upsert is implemented with the ON CONFLICT clause added to INSERT. ON CONFLICT (column) specifies which unique constraint defines a conflict — if the INSERT would violate that constraint, the conflict handler runs instead of throwing an error. ON CONFLICT DO NOTHING silently skips the conflicting row. ON CONFLICT DO UPDATE SET col = EXCLUDED.col updates the existing row with the new values — EXCLUDED is a special table reference to the row that was attempted to be inserted.
A concrete example: syncing a product catalogue from a supplier feed that sends all products daily. Products already in the database should have their prices updated. New products should be inserted. INSERT INTO products (sku, name, price) SELECT sku, name, price FROM supplier_feed ON CONFLICT (sku) DO UPDATE SET price = EXCLUDED.price, updated_at = NOW() — this single statement handles both cases atomically, without needing separate INSERT and UPDATE logic in application code.
Positional INSERT — INSERT INTO table VALUES (val1, val2, val3) without listing column names — requires values in the exact order columns were defined in CREATE TABLE. This order is invisible to anyone reading the query. If the schema changes — a column is added, removed, or reordered — the positional INSERT silently maps values to the wrong columns with no error. A new column added in position 3 causes the old value that belonged to the old column 3 to now be inserted into the new column.
Named INSERT — INSERT INTO table (col1, col2, col3) VALUES (val1, val2, val3) — is schema-change resistant. Adding a new column to the table does not affect existing named INSERTs — the new column receives its DEFAULT value or NULL, and all other columns receive their specified values. The mapping between column names and values is explicit, visible, and cannot be broken by column reordering.
Named INSERT also makes queries self-documenting — anyone reading the statement can immediately see what each value represents without needing to look up the table definition. For a table with 15 columns, a positional INSERT with 15 values is completely unreadable without the schema. A named INSERT with 15 column-value pairs is self-explanatory. The professional standard: always name columns in INSERT statements, with no exceptions.
SQL injection is an attack where malicious input data is interpreted as SQL commands rather than as data values. It occurs when application code builds SQL statements by concatenating user input directly into the SQL string. If a user enters '; DROP TABLE customers; -- as their name, and the application builds the query as INSERT INTO customers (name) VALUES ('' + name + ''), the resulting SQL is INSERT INTO customers (name) VALUES (''); DROP TABLE customers; -- '), which executes two statements: the INSERT and a DROP TABLE that deletes the entire customers table.
SQL injection is consistently ranked as a top web security vulnerability and has caused some of the largest data breaches in history. It requires no special tools — just crafted input in a web form, an API parameter, or a URL query string. The vulnerability is not in the database — it is in the application code that builds queries with string concatenation.
Parameterised queries (also called prepared statements) prevent SQL injection by separating SQL structure from data values. The SQL template is sent to the database first: INSERT INTO customers (name) VALUES ($1). Then the value is sent separately: ['Sofia']. The database driver handles all escaping and quoting — user input is always treated as a data value, never as SQL syntax. The malicious '; DROP TABLE customers; -- input would be stored as a literal string in the name column, doing no harm. Parameterised queries are mandatory for any application that accepts user input and are supported by every major database driver in every language.
RETURNING is a PostgreSQL (and DuckDB) extension to INSERT, UPDATE, and DELETE that returns column values from the affected rows without requiring a separate SELECT. After INSERT, RETURNING most commonly returns the auto-generated primary key — which the application needs to reference the new row in subsequent operations. INSERT INTO orders (...) VALUES (...) RETURNING order_id returns the generated order_id immediately, which the application then uses to insert the corresponding order_items rows.
Without RETURNING, getting the generated ID requires a second query: SELECT MAX(order_id) FROM orders (unreliable in concurrent systems — another INSERT between your INSERT and SELECT could return the wrong ID) or a database-specific function like LAST_INSERT_ID() in MySQL (connection-scoped but only available immediately after the INSERT).
RETURNING is also valuable for: confirming what was actually inserted (useful when DEFAULT values or triggers modify the row during INSERT), getting computed values that the database added (created_at timestamp, auto-generated codes), and chaining operations where the INSERT output feeds the next step. In an API that creates an order and returns the complete order record, RETURNING * on the INSERT avoids a SELECT round-trip to fetch the created record. MySQL does not support RETURNING — use LAST_INSERT_ID() or a subsequent SELECT for MySQL compatibility.
// Part 12
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Always list column names in INSERT — INSERT INTO table (col1, col2) VALUES (val1, val2). Never use positional INSERT. Schema changes silently corrupt positional inserts.
- ✓Multi-row INSERT — VALUES (a1,b1), (a2,b2), (a3,b3) — is 10–100x faster than individual inserts for bulk operations. One round-trip, one transaction, one constraint check.
- ✓INSERT INTO ... SELECT copies and transforms data from existing tables. Always run the SELECT alone first to verify row count and values before committing the insert.
- ✓RETURNING (PostgreSQL/DuckDB) returns column values from inserted rows — essential for capturing auto-generated primary keys without a separate SELECT.
- ✓ON CONFLICT DO NOTHING skips conflicting rows silently. ON CONFLICT DO UPDATE performs a true upsert — insert if new, update if exists. Both require specifying the conflicting constraint.
- ✓Always wrap related INSERTs in a transaction — BEGIN; INSERT...; INSERT...; COMMIT. A partial insert (parent without children) leaves the database in an inconsistent state.
- ✓Never build SQL strings by concatenating user input. Use parameterised queries — the database driver handles escaping and user input can never be interpreted as SQL.
- ✓COPY (PostgreSQL) and LOAD DATA INFILE (MySQL) are 10–100x faster than INSERT for large bulk loads — millions of rows from CSV files in seconds.
- ✓The verify-before-insert pattern for bulk loads: check NULLs in required fields, check internal duplicates, check FK conflicts with production data, run in a transaction, verify the inserted count afterward.
- ✓MySQL uses LAST_INSERT_ID() to get the generated PK after INSERT. MySQL does not support RETURNING — it is PostgreSQL-specific.
What comes next
In Lesson 21, you learn UPDATE — modifying existing rows, updating multiple columns at once, updating from another table, and the most important safety rule in SQL: always run SELECT before UPDATE.
Lesson 21 → UPDATEQuick check
Why should an INSERT list its column names?
HELP US IMPROVE
How was this lesson?
Your note is shared with the Chaduvuko team for review. Please don’t include personal or sensitive information.
Spotted a mistake or something unclear? Suggest a fix on GitHub or email us. Select the text first and it is quoted for you.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.
