DML: Read, Add, Change, and Remove Rows
Data Manipulation Language (DML) works with table data. SELECT reads rows, INSERT adds them, UPDATE changes them, and DELETE removes them. The table's structure remains in place unless a separate schema-changing command alters it.
Use the SQLite bookstore setup from the SQL introduction. The write examples below are independent exercises wrapped in a transaction and rolled back, so they do not leave the sample database changed. A transaction is a group of database work; TCL explains it in detail.
The bookstore rows used by these examples are:
products
| product_id | title | price_cents | stock |
|---|---|---|---|
| 10 | Database Basics | 1500 | 5 |
| 20 | SQL Practice | 2500 | 8 |
| 30 | History of Computing | 1800 | 0 |
customers
| customer_id | name | |
|---|---|---|
| 1 | Alice | alice@example.com |
| 2 | Bob | bob@example.com |
| 3 | Carol | carol@example.com |
Read only the data you need
SELECT product_id, title, stock
FROM products
WHERE stock = 0
ORDER BY product_id;
Result:
| product_id | title | stock |
|---|---|---|
| 30 | History of Computing | 0 |
WHERE chooses rows, while the SELECT list chooses columns.
SELECT * requests all columns. That can be convenient while exploring, but listing needed columns makes application expectations clearer and avoids transferring unused data.
Add a row with INSERT
BEGIN;
INSERT INTO customers (customer_id, name, email)
VALUES (4, 'Dana', 'dana@example.com');
SELECT customer_id, name FROM customers WHERE customer_id = 4;
ROLLBACK;
The select sees Dana because a transaction sees its own writes. Its result is:
| customer_id | name |
|---|---|
| 4 | Dana |
The rollback removes that uncommitted insert. After the exercise, only the original three customers shown above remain.
The column list and value list correspond by position: 4 goes into customer_id, Dana into name, and the email into email. Explicit column lists make that correspondence visible and reduce dependence on a table's column order.
Inserting an existing customer ID or duplicate required email violates a declared rule and is rejected. A successful insert creates a row; it does not automatically send a welcome email or validate email ownership.
Change a targeted row with UPDATE
BEGIN;
UPDATE products
SET price_cents = 1600
WHERE product_id = 10;
SELECT product_id, price_cents FROM products WHERE product_id = 10;
ROLLBACK;
The select result inside the transaction is:
| product_id | price_cents |
|---|---|
| 10 | 1600 |
The rollback restores 1500. Existing order-item purchase prices do not change: they describe the amount charged at purchase time, not the product's current offer.
SET specifies the new value. WHERE specifies which rows receive it. Omitting WHERE applies the update to every row. That is valid for an intentional bulk change, but disastrous for an operation intended to affect one product.
Use current values for a relative change
BEGIN;
UPDATE products
SET stock = stock + 2
WHERE product_id = 10;
SELECT stock FROM products WHERE product_id = 10;
ROLLBACK;
The select result inside the transaction is:
| stock |
|---|
| 7 |
The database calculates the new value from the row's current value. This is preferable to unnecessarily reading five into application code and later writing a stale calculated value.
For a limited-stock purchase, the condition matters too:
UPDATE products
SET stock = stock - 1
WHERE product_id = 30 AND stock > 0;
Product 30 has zero stock, so this affects zero rows. It is not a SQL error. The application must inspect the affected-row count and avoid recording a sale when the decrement did not happen.
Remove a row with DELETE
Try deletion on a newly inserted practice customer:
BEGIN;
INSERT INTO customers (customer_id, name, email)
VALUES (4, 'Dana', 'dana@example.com');
DELETE FROM customers WHERE customer_id = 4;
SELECT customer_id FROM customers WHERE customer_id = 4;
ROLLBACK;
The select result has no rows:
| customer_id |
|---|
The customer table still exists. Deleting a customer with referenced orders is different: the foreign-key policy decides whether the deletion is rejected or another declared action happens.
Distinguish the common outcomes
| Outcome | Meaning | Application response |
|---|---|---|
| One target row changed | The statement found and modified the intended record, subject to engine count semantics. | Continue after validating the workflow. |
| Zero rows affected | No qualifying row was changed. | Decide whether this means missing data, no stock, or a conflict. |
| Constraint error | The proposed change violates a declared rule. | Explain the problem and handle the surrounding transaction. |
| Connection lost during commit | The client may not know whether work committed. | Resolve the outcome using a stable request ID or another safe protocol. |
Success at the SQL statement level is not always success at the business-operation level. A zero-row update is the simplest example of that distinction.
Transform values in a result before deciding to store them
A scalar function works on a value in each row. It does not combine an entire group like SUM does:
SELECT customer_id,
UPPER(name) AS mailing_label,
LOWER(email) AS email_label
FROM customers
ORDER BY customer_id;
SELECT product_id,
CASE WHEN stock = 0 THEN 'unavailable' ELSE 'available' END AS availability
FROM products
ORDER BY product_id;
The first query returns:
| customer_id | mailing_label | email_label |
|---|---|---|
| 1 | ALICE | alice@example.com |
| 2 | BOB | bob@example.com |
| 3 | CAROL | carol@example.com |
The second query returns:
| product_id | availability |
|---|---|
| 10 | available |
| 20 | available |
| 30 | unavailable |
The stored names and stock values are unchanged. CASE chooses one result according to a condition; it is useful for report labels without introducing redundant stored columns.
SQLite's built-in case conversions are not a complete solution for every language's Unicode rules. Normalizing email addresses also requires an application policy rather than an assumption that all text can safely be lowercased.
TRIM removes surrounding characters, CAST asks for a conversion, and COALESCE provides a fallback for null. Converting malformed input to a number is not validation: SQLite can turn some invalid numeric text into zero. Validate the source and choose suitable column rules before writing data.
Insert a result set, not just literal rows
A report snapshot can collect several rows with one command:
CREATE TABLE dml_stock_snapshot (
product_id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
stock INTEGER NOT NULL,
captured_on TEXT NOT NULL
);
INSERT INTO dml_stock_snapshot (product_id, title, stock, captured_on)
SELECT product_id, title, stock, '2025-01-13'
FROM products
WHERE stock > 0;
SELECT product_id, stock FROM dml_stock_snapshot ORDER BY product_id;
Result:
| product_id | stock |
|---|---|
| 10 | 5 |
| 20 | 8 |
INSERT ... SELECT inserts the rows returned by its source query; it does not create a continuing link to the source. A later inventory update will not rewrite this snapshot. That is correct only if the table means “stock captured at this time.” For repeated snapshots, include the capture identifier in the key rather than overwriting history accidentally.
Specify the destination columns. Relying on every column's current physical order makes an import fragile when the table definition changes. The number and meanings of the selected expressions must match those destination columns.
Handle an existing key deliberately
SQLite supports an upsert: insert a row, or update it when a specified unique key conflicts. This is useful for synchronizing a supplier's current catalog:
CREATE TABLE dml_supplier_catalog (
supplier_sku TEXT PRIMARY KEY,
title TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents >= 0)
);
INSERT INTO dml_supplier_catalog VALUES ('BK-10', 'Database Basics', 1500);
Input (dml_supplier_catalog):
| supplier_sku | title | price_cents |
|---|---|---|
| BK-10 | Database Basics | 1500 |
INSERT INTO dml_supplier_catalog (supplier_sku, title, price_cents)
VALUES ('BK-10', 'Database Basics, revised', 1600)
ON CONFLICT (supplier_sku) DO UPDATE
SET title = excluded.title,
price_cents = excluded.price_cents;
SELECT supplier_sku, title, price_cents FROM dml_supplier_catalog;
Result:
| supplier_sku | title | price_cents |
|---|---|---|
| BK-10 | Database Basics, revised | 1600 |
excluded refers to the proposed insert's values. The conflict target must correspond to a primary key or uniqueness rule. This update policy accepts the incoming source as current; a real integration may also need a version or timestamp to prevent an older message replacing newer data.
SQLite's INSERT OR REPLACE is not interchangeable with an update. It can delete a conflicting row and insert a replacement, affecting references, triggers, and generated identifiers. PostgreSQL also has ON CONFLICT, while MySQL has different upsert syntax. Check the engine's exact behavior before porting synchronization code.
Get values from the write itself
SQLite 3.35.0 and later support RETURNING:
BEGIN;
UPDATE products
SET stock = stock - 1
WHERE product_id = 10 AND stock > 0
RETURNING product_id, stock;
ROLLBACK;
The update result is:
| product_id | stock |
|---|---|
| 10 | 4 |
The rollback restores 5 units. Returning the affected row avoids a separate query just to discover the direct change. No returned row means this statement found no qualifying product. It does not tell you whether the product was missing or out of stock; decide whether the application needs to distinguish those cases.
RETURNING reports rows changed directly by this statement. It is not a general report of all changes made by triggers. PostgreSQL supports a similar clause; SQL Server uses OUTPUT for related purposes.
Understand how null changes a condition
Use a separate optional-contact table:
CREATE TABLE dml_optional_contacts (
contact_id INTEGER PRIMARY KEY,
phone TEXT
);
INSERT INTO dml_optional_contacts VALUES (1, NULL), (2, ''), (3, '12345');
Input (dml_optional_contacts):
| contact_id | phone |
|---|---|
| 1 | NULL |
| 2 | '' |
| 3 | 12345 |
SELECT contact_id FROM dml_optional_contacts WHERE phone IS NULL;
SELECT contact_id FROM dml_optional_contacts WHERE phone = '';
SELECT contact_id FROM dml_optional_contacts WHERE phone <> '12345';
Result of phone IS NULL:
| contact_id |
|---|
| 1 |
Result of phone = '':
| contact_id |
|---|
| 2 |
Result of phone <> '12345':
| contact_id |
|---|
| 2 |
A null phone does not satisfy phone <> '12345'; that comparison is unknown. If the business question includes missing phones, write phone IS NULL OR phone <> '12345' explicitly. Replacing nulls with empty strings silently erases the distinction between “unknown” and “known to be empty.”
The same issue affects NOT IN when its list or subquery contains a null. An anti-match with NOT EXISTS is often clearer, as demonstrated in the joins note.
Bind values and check the write's outcome
This Python helper assumes an existing SQLite connection containing the bookstore. Call it when that connection has no unrelated transaction in progress:
def change_price(connection, product_id, price_cents):
if not isinstance(price_cents, int) or price_cents < 0:
raise ValueError('price must be nonnegative integer cents')
with connection:
cursor = connection.execute(
'UPDATE products SET price_cents = ? WHERE product_id = ?',
(price_cents, product_id),
)
if cursor.rowcount != 1:
raise ValueError('product does not exist')
With Python's default SQLite transaction handling, the context commits a successful write and rolls it back when the exception escapes. It does not close the connection. Custom autocommit settings change that behavior; use the connection configuration described in the transaction note.
The application validates the intended unit and reports a missing product. The database check remains useful because imports, other services, and administrative tools can write through different paths. Neither layer substitutes for the other. An expected uniqueness conflict, an unavailable connection, and invalid input also deserve different handling; catching every error and claiming success loses that information.
References: SQLite UPSERT and SQLite RETURNING.
Check your understanding
- What does the sample contain after each rolled-back exercise?
- Why does changing a current product price leave past purchase prices alone?
- What happens if an update intended for product 10 has no
WHEREclause? - Why must a sale attempt check the affected-row count?
Continue with permissions to decide who may perform these operations, and transactions to group related changes.