Querying rows_
Read and write MySQL rows with SELECT, INSERT, UPDATE, and DELETE. Covers filtering, aggregation, pagination, and upserts.
6 min read
Four statements do almost all the work in a relational database:
- SELECT reads rows
- INSERT adds new rows
- UPDATE changes existing rows
- DELETE removes rows
SQL is declarative: a query describes the result you want, and the database's optimizer decides how to produce it, choosing between indexes, scans, and join strategies on its own. That's why the same query keeps working as data grows and indexes change.
This page walks through each statement, plus the querying patterns you reach for daily: filtering, aggregation, pagination, and upserts.
Setup
The examples below work against a small product catalog. Create and seed it first:
CREATE TABLE products ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 0);
INSERT INTO products (name, price, stock) VALUES ('Keyboard', 79.00, 120), ('Mouse', 29.50, 200), ('Monitor', 349.99, 14), ('Webcam', 59.00, 0), ('Desk mat', 19.90, 45);Read rows
A SELECT statement has three jobs, each handled by its own clause: choose which columns to return, decide which rows qualify, and put them in order:
SELECT name, priceFROM productsWHERE stock > 0ORDER BY price DESC;name priceMonitor 349.99Keyboard 79.00Mouse 29.50Desk mat 19.90Four of the five products come back: the Webcam is filtered out because its stock is 0, and the rest arrive sorted by price, highest first. The other columns still exist on those rows; the query just didn't ask for them. Without an ORDER BY, row order is arbitrary and can change between runs, so always order results the user will see.
WHERE accepts any boolean expression, built from a handful of operators:
- Comparisons (
=,<,>,<=,>=,<>) work on numbers, text, and dates alike. - BETWEEN matches a range, bounds included.
- LIKE matches string patterns, where
%stands for any sequence of characters; under the defaultutf8mb4collation it compares case-insensitively, so'%mo%'matches both Mouse and Monitor. - IN matches any value in a list.
- AND and OR combine conditions, with parentheses to group them.
SELECT name FROM products WHERE price BETWEEN 20 AND 100; -- rangeSELECT name FROM products WHERE name LIKE '%mo%'; -- pattern matchSELECT name FROM products WHERE stock IN (0, 14); -- membershipSELECT name FROM products WHERE stock = 0 OR price < 25; -- combined conditionsAvoid SELECT * in application code. Naming columns keeps results stable when the table gains columns later and avoids transferring data you don't use.
Insert rows
INSERT names the columns it provides and gives a value for each; omitted columns fall back to their defaults, like the auto-generated id here:
INSERT INTO products (name, price, stock)VALUES ('USB hub', 24.00, 80);
SELECT LAST_INSERT_ID();LAST_INSERT_ID() returns the AUTO_INCREMENT value generated by the most recent insert on this connection. It is connection-scoped, so concurrent clients never see each other's IDs. Most drivers surface it directly on the statement result, no extra query needed.
Update rows
UPDATE changes the columns you name on every row matching the WHERE clause:
UPDATE productsSET price = price * 0.90WHERE stock > 100;The SET expression can reference the row's current values, as the discount above does. Without a WHERE clause, UPDATE rewrites the entire table, so check the target set first with a SELECT using the same WHERE when running one by hand.
Delete rows
DELETE removes every row matching the WHERE clause:
DELETE FROM productsWHERE stock = 0;Without a WHERE clause, DELETE FROM products removes every row in the table. For hand-run maintenance, wrap the statement in a transaction so you can inspect the result and roll it back. See Transactions.
Aggregate
An aggregate function computes a single value over a set of rows, such as a count of orders or a total spend per customer:
- COUNT(*) counts rows
- SUM and AVG total and average a numeric column
- MIN and MAX find the extremes
The demonstration needs a table where several rows belong to each customer:
CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, customer VARCHAR(100) NOT NULL, total DECIMAL(10, 2) NOT NULL);
INSERT INTO orders (customer, total) VALUES ('ada', 120.00), ('ada', 80.00), ('grace', 45.50), ('grace', 30.00), ('grace', 25.00), ('linus', 999.99);By default an aggregate covers every row in the table: SELECT SUM(total) FROM orders returns one number, the total across all six orders. GROUP BY customer changes that. The database first gathers rows with the same customer value together, Ada's two orders, Grace's three, Linus's one, then runs the aggregate functions once for each of those groups. The result has one row per customer instead of one row for the whole table:
SELECT customer, COUNT(*) AS orders, SUM(total) AS lifetime_valueFROM ordersGROUP BY customerHAVING SUM(total) > 100ORDER BY lifetime_value DESC;customer orders lifetime_valuelinus 1 999.99ada 2 200.00grace 3 100.50Six order rows became three result rows, one per customer:
- Ada's two orders were summed into 200.00
- Grace's three orders were summed into 100.50
- Linus's single order stands alone at 999.99
In a grouped query, every selected column must be either grouped on (like customer) or aggregated (like SUM(total)), because each result row now represents many source rows.
WHERE and HAVING both filter, but at different stages:
- WHERE filters individual rows, before grouping. It cannot use aggregate results, because they haven't been computed yet.
- HAVING filters the groups, after the aggregates are computed.
Here HAVING SUM(total) > 100 keeps only customers whose orders total more than 100. Grace passes at 100.50; a threshold of 150 would exclude her.
Paginate
Pagination splits a large result into pages. There are two ways to do it:
- Offset pagination asks for "skip the first 20 rows, return the next 10". Simple, but the database still reads and discards every skipped row, so deep pages get slower and slower.
- Keyset pagination asks for "return the 10 rows after the last one I saw". The database jumps straight to that position, so every page costs the same as the first.
LIMIT and OFFSET are the offset approach:
SELECT id, name FROM products ORDER BY id LIMIT 2 OFFSET 2;Keyset pagination filters on the last value seen instead:
SELECT id, nameFROM productsWHERE id > 2 -- last id from the previous pageORDER BY idLIMIT 2;Keyset pagination stays fast at any depth because the index seeks straight to the boundary, provided the ordering columns are indexed. It requires a deterministic, unique ordering (a single column like id, or a composite) and can't jump to an arbitrary page number.
Upsert
An upsert writes a row without knowing whether it already exists: insert it if it's new, update it if it's not. Stock counts, settings, and sync jobs all need this.
The obvious approach, SELECT to check and then INSERT or UPDATE accordingly, has a race condition: another connection can insert the same key between your check and your write, and your insert fails.
INSERT ... ON DUPLICATE KEY UPDATE performs the check and the write as one atomic statement, so the race cannot happen:
CREATE TABLE inventory ( sku VARCHAR(20) PRIMARY KEY, quantity INT NOT NULL);
INSERT INTO inventory (sku, quantity) VALUES ('KB-01', 10);
-- Second write for the same key updates instead of failingINSERT INTO inventory (sku, quantity) VALUES ('KB-01', 5) AS newON DUPLICATE KEY UPDATE quantity = inventory.quantity + new.quantity;
SELECT * FROM inventory;sku quantityKB-01 15What happened:
- The first insert creates KB-01 with quantity 10.
- The second insert collides with it on the primary key, so instead of failing, it runs the
ON DUPLICATE KEY UPDATEclause: existing 10 plus incoming 5 gives 15. - The
AS newalias names the row that would have been inserted, which is how the update reaches the incoming values.
To silently ignore duplicates instead, use INSERT IGNORE, but note it downgrades other errors to warnings too, so prefer ON DUPLICATE KEY UPDATE when in doubt.
Subqueries and CTEs
A common table expression (CTE) names an intermediate result so a complex query reads top to bottom:
WITH big_spenders AS ( SELECT customer FROM orders GROUP BY customer HAVING SUM(total) > 150)SELECT customer FROM big_spenders ORDER BY customer;CTEs and subqueries are interchangeable in most positions; prefer whichever keeps the query readable. MySQL also supports WITH RECURSIVE for hierarchical data such as category trees.
Was this page helpful?
Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.