Getting Started
Connect to MySQL
The mysql client is the standard CLI for MySQL. Always use -p (no space after) so the password is prompted securely rather than visible in shell history. -h sets the host, -P (capital) sets the port. \s prints connection status. Use -e to run one-off queries in scripts.
# connect to local server (prompt for password)
mysql -u root -p
# connect to a remote host on a custom port
mysql -h 192.168.1.100 -P 3307 -u admin -p
# connect directly to a specific database
mysql -u root -p mydb
# non-interactive: run a query and exit
mysql -u root -p -e "SELECT VERSION();"
# show server status inside the client
\s
SELECT VERSION(), CURRENT_USER, DATABASE();Database Management
Use utf8mb4 (not utf8) to support full Unicode including emoji — MySQL's utf8 is a 3-byte subset that cannot store all characters. utf8mb4_unicode_ci is the recommended collation for correct sorting. IF EXISTS/IF NOT EXISTS prevent errors in scripts. DROP DATABASE removes all tables and data instantly.
# list all databases
SHOW DATABASES;
# create a database with charset and collation
CREATE DATABASE mydb
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
# switch to a database
USE mydb;
# show the current database
SELECT DATABASE();
# drop a database (irreversible)
DROP DATABASE IF EXISTS mydb;
# alter a database's charset
ALTER DATABASE mydb CHARACTER SET utf8mb4;Show & Describe Objects
SHOW commands are MySQL-specific introspection tools. DESCRIBE gives a quick column overview; SHOW CREATE TABLE gives the full DDL you can reuse. Append \G instead of ; for vertical output — much more readable for wide rows like SHOW CREATE TABLE.
# list tables in the current database
SHOW TABLES;
# show table structure (columns, types, keys)
DESCRIBE users;
# equivalent
SHOW COLUMNS FROM users;
# show create statement for a table
SHOW CREATE TABLE users\G
# list indexes on a table
SHOW INDEX FROM users;
# show stored procedures / functions
SHOW PROCEDURE STATUS WHERE Db = 'mydb';
SHOW FUNCTION STATUS WHERE Db = 'mydb';
# list triggers
SHOW TRIGGERS\GServer Status & Variables
MySQL variables have SESSION (current connection) and GLOBAL scopes. SET GLOBAL changes the running server but resets on restart — persist settings in my.cnf instead. @@var reads values. SHOW STATUS exposes runtime counters (connections, uptime, throughput) useful for monitoring.
# view a system variable
SHOW VARIABLES LIKE 'max_connections';
SHOW VARIABLES LIKE 'version%';
# view session vs global variable
SELECT @@session.sql_mode, @@global.sql_mode;
# set a variable for the current session
SET sql_mode = 'STRICT_TRANS_TABLES';
SET @@session.foreign_key_checks = 0;
# set globally (needs SUPER/privilege, persists until restart)
SET GLOBAL max_connections = 200;
# show server status counters
SHOW STATUS LIKE 'Threads%';
SHOW STATUS LIKE 'Uptime';Comments & Statement Basics
MySQL supports three comment styles: -- (requires a trailing space), /* */ blocks, and # to end of line. /*! ... */ is a special executable comment — code inside runs only on MySQL and is ignored by other SQL engines, useful for portable schema files. \G executes and displays results vertically.
-- single-line comment (note the space after --)
SELECT 1; -- inline comment
/* multi-line
comment block */
SELECT 2;
# hash-style comment (MySQL-specific, to end of line)
SELECT 3;
# MySQL executable comments: run only on MySQL
SELECT 1 /*!50100 , 2 */; /* the part runs on MySQL >= 5.1 */
# statements end with semicolon; \G ends and prints vertically
SELECT * FROM users\GConfiguration File (my.cnf)
my.cnf (Linux) / my.ini (Windows) holds persistent server and client settings organized in sections: [mysqld] for the server, [client]/[mysql] for the client. innodb_buffer_pool_size is the single most important tuning knob for InnoDB (typically 50-75% of RAM). After editing, restart the server. Use mysql --help or SHOW VARIABLES to verify.
# /etc/my.cnf or ~/.my.cnf (Linux/macOS)
# C:\ProgramData\MySQL\MySQL Server 8.0\my.ini (Windows)
[mysqld]
port = 3306
datadir = /var/lib/mysql
max_connections = 200
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 2G
slow_query_log = 1
long_query_time = 2
[client]
default-character-set = utf8mb4
[mysql]
prompt = \u@\h [\d]>\_DDL Operations
Create Table
CREATE TABLE defines columns, types, constraints and table options. InnoDB is the default and recommended engine (supports transactions, row-level locking, foreign keys). BIGINT UNSIGNED for AUTO_INCREMENT avoids overflow. DECIMAL(p,s) is exact for money. ENUM validates against a fixed list. TIMESTAMP DEFAULT CURRENT_TIMESTAMP auto-fills on insert.
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
birth_date DATE NULL,
status ENUM('active','inactive','banned') NOT NULL DEFAULT 'active',
balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Alter Table
ALTER TABLE evolves schema without recreating data. ADD COLUMN with AFTER places it in a specific position (column order is cosmetic in MySQL). MODIFY changes type/default; RENAME COLUMN (8.0+) is cleaner than CHANGE. Large ALTER operations may rebuild and lock the table — use pt-online-schema-change or online DDL for big tables. RENAME TABLE is atomic.
# add a column
ALTER TABLE users
ADD COLUMN phone VARCHAR(20) NULL AFTER email;
# add multiple columns
ALTER TABLE users
ADD COLUMN first_name VARCHAR(50) NULL,
ADD COLUMN last_name VARCHAR(50) NULL;
# modify a column type
ALTER TABLE users
MODIFY COLUMN phone VARCHAR(30) NOT NULL;
# rename a column (MySQL 8+ preserves data)
ALTER TABLE users
RENAME COLUMN phone TO phone_number;
# rename a table
RENAME TABLE users TO members;
ALTER TABLE users RENAME TO members;Drop & Truncate Table
DROP TABLE removes the table entirely; TRUNCATE empties it but keeps the structure and resets AUTO_INCREMENT to its start value. TRUNCATE is faster than DELETE because it skips per-row deletion and logging, but it is DDL (cannot be rolled back, fires no triggers). Disable foreign_key_checks when truncating tables referenced by others.
# drop a table (removes structure and data)
DROP TABLE IF EXISTS old_logs;
# drop multiple tables
DROP TABLE IF EXISTS temp1, temp2;
# truncate: empty the table, keep structure, reset AUTO_INCREMENT
TRUNCATE TABLE session_data;
# truncate cannot be rolled back in some engines
# (InnoDB: TRUNCATE is DDL, implicitly commits)
SET foreign_key_checks = 0;
TRUNCATE TABLE child_table;
SET foreign_key_checks = 1;AUTO_INCREMENT
AUTO_INCREMENT generates sequential numbers for a primary key — each table may have only one. LAST_INSERT_ID() returns the id generated by the most recent INSERT in the current session (per-connection, safe for concurrent use). Gaps can appear after deletes, rollbacks, or inserts that specify explicit values. To reset, use ALTER TABLE ... AUTO_INCREMENT = n.
CREATE TABLE orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
amount DECIMAL(10,2)
);
# set the next auto-increment value
ALTER TABLE orders AUTO_INCREMENT = 1000;
# insert without specifying id
INSERT INTO orders (amount) VALUES (99.50);
# get the last inserted id
SELECT LAST_INSERT_ID();
# show current AUTO_INCREMENT value
SHOW TABLE STATUS LIKE 'orders'\GTable Constraints
Constraints enforce data integrity at the database level. InnoDB supports primary keys, UNIQUE, NOT NULL, CHECK (enforced since 8.0.16) and foreign keys. FOREIGN KEY ... ON DELETE CASCADE removes child rows when the parent is deleted; SET NULL nullifies them; RESTRICT/NO ACTION block deletion. Name constraints explicitly for easier management. Foreign keys require indexes on both columns.
CREATE TABLE orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
# unique constraint
CONSTRAINT uq_order_no UNIQUE (user_id, status),
# check constraint (MySQL 8.0.16+ enforced)
CONSTRAINT chk_total CHECK (total >= 0),
# foreign key with cascading actions
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE RESTRICT
) ENGINE=InnoDB;
# add a constraint later
ALTER TABLE orders
ADD CONSTRAINT chk_status CHECK (status IN ('paid','shipped','cancelled'));Temporary Tables
TEMPORARY tables exist only for the current session and are dropped automatically when it closes. They can shadow a real table of the same name, useful for safe refactoring or staging data. Two sessions can create temporary tables with the same name without conflict. They are not visible to other connections and are not written to the binlog by default.
# session-scoped, auto-dropped on disconnect
CREATE TEMPORARY TABLE tmp_active
SELECT id, username FROM users WHERE status = 'active';
# identical name shadowing a real table
CREATE TEMPORARY TABLE users
(id INT, name VARCHAR(50));
# only this session sees the temp table
SELECT * FROM users;
# explicit cleanup
DROP TEMPORARY TABLE IF EXISTS tmp_active;Data Types
Numeric Types
Use DECIMAL for monetary values where exactness matters — FLOAT/DOUBLE are approximate and accumulate rounding errors. UNSIGNED doubles the positive range but disallows negatives. Display width (e.g. INT(11)) is deprecated in MySQL 8.0 and ignored — it never limited the stored range. BIGINT UNSIGNED is recommended for AUTO_INCREMENT to avoid overflow. SERIAL is a handy shortcut for a surrogate key.
# integers (with optional UNSIGNED)
TINYINT -- 1 byte, -128..127 or 0..255 (UNSIGNED)
SMALLINT -- 2 bytes
MEDIUMINT -- 3 bytes
INT -- 4 bytes
BIGINT -- 8 bytes (use for large AUTO_INCREMENT)
# fixed-point (exact, for money)
DECIMAL(10,2) -- 10 digits total, 2 after decimal
NUMERIC(8,4)
# floating-point (approximate)
FLOAT -- 4 bytes
DOUBLE -- 8 bytes
# other
BIT(8) -- bit-field
SERIAL -- alias for BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUEString & Text Types
VARCHAR is almost always preferred over CHAR (which space-pads) except for short fixed-length codes. For MySQL's utf8mb4 each char can take up to 4 bytes — a VARCHAR(255) column needs up to 1020 bytes plus length, which matters for index key limits (3072 bytes in InnoDB). TEXT/BLOB families store large data off-page; they cannot have a default value and indexing them requires a prefix length.
# fixed vs variable length
CHAR(10) -- fixed 10 chars, padded with spaces
VARCHAR(255) -- variable, up to 255 chars (stores actual length)
# large text
TINYTEXT -- up to 255 bytes
TEXT -- up to 64 KB
MEDIUMTEXT -- up to 16 MB
LONGTEXT -- up to 4 GB
# binary counterparts
BINARY(16), VARBINARY(255), BLOB, MEDIUMBLOB, LONGBLOB
# national character set
NATIONAL VARCHAR(100) -- same as VARCHAR with utf8mb4
# VARCHAR with charset counts characters, not bytes
VARCHAR(100) CHARACTER SET utf8mb4Date & Time Types
DATETIME stores a literal date/time with no timezone awareness; TIMESTAMP stores UTC and converts to the session's time_zone on display, making it better for cross-timezone apps — but it has a 2038 limit on 32-bit builds. Use DATETIME(6)/TIMESTAMP(6) for microsecond precision. CURRENT_TIMESTAMP works as a default for both since MySQL 5.6.5. Set session time_zone per connection with SET time_zone = '+08:00';.
DATE -- 'YYYY-MM-DD', range 1000-01-01 .. 9999-12-31
TIME -- 'HH:MM:SS', can be negative for elapsed time
DATETIME -- 'YYYY-MM-DD HH:MM:SS', 8 bytes, no timezone
TIMESTAMP -- stored as UTC, converted to session timezone, 4 bytes
YEAR -- YEAR(4) e.g. 2024
# DATETIME with fractional seconds and auto-default
CREATE TABLE events (
ts DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3)
);
# TIMESTAMP range: 1970-01-01 .. 2038-01-19 (32-bit limit)
# MySQL 8.0.28+ extends TIMESTAMP to 24 bits of rangeENUM & SET Types
ENUM stores one of a fixed list of string values compactly (1-2 bytes) and rejects unknown values in strict mode. SET stores any combination of up to 64 members as a bitmask. Both make schema changes costly (adding/reordering members needs ALTER TABLE) and are awkward to query — many teams prefer a lookup table or VARCHAR with CHECK instead. FIND_IN_SET helps query SET columns.
# ENUM: exactly one value from a list
CREATE TABLE shirts (
size ENUM('S','M','L','XL') NOT NULL DEFAULT 'M',
color ENUM('red','green','blue') NULL
);
# stored as integer index (1-based), space-efficient
INSERT INTO shirts (size) VALUES ('L'), ('XL');
# SET: zero or more values from a list (bit flags)
CREATE TABLE posts (
tags SET('news','tech','sports','fun') NOT NULL DEFAULT ''
);
INSERT INTO posts (tags) VALUES ('tech,fun');
# query SET members with FIND_IN_SET
SELECT * FROM posts WHERE FIND_IN_SET('tech', tags);JSON Type
The JSON type (MySQL 5.7.8+) stores JSON in a binary format that is validated on write and supports fast member access. It is preferable to storing JSON in a TEXT column because you can index and query inside it. Use JSON_OBJECT(), JSON_ARRAY() and JSON_MERGE_PATCH() to build values. For frequent querying of a few keys, extract them into generated columns and index those for best performance.
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100),
attrs JSON NOT NULL
);
# insert JSON literals
INSERT INTO products (id, name, attrs) VALUES
(1, 'Widget', '{"color":"red","size":42,"tags":["new","sale"]}'),
(2, 'Gadget', '{"color":"blue","in_stock":true}');
# JSON values are validated on insert and stored binary
SELECT attrs FROM products WHERE id = 1;
# pretty-print JSON
SELECT JSON_PRETTY(attrs) FROM products WHERE id = 1;BLOB & Binary Types
BLOB stores arbitrary binary data (images, files) but is awkward to query and cannot have a default. For large files, prefer storing a path in the DB and the file on disk/object storage. Store UUIDs as BINARY(16) (16 bytes) instead of CHAR(36) (36 bytes) for index efficiency — MySQL 8.0 adds UUID_TO_BIN()/BIN_TO_UUID() with optional swap for index friendliness.
# binary large objects
TINYBLOB -- up to 255 bytes
BLOB -- up to 64 KB
MEDIUMBLOB -- up to 16 MB
LONGBLOB -- up to 4 GB
# fixed-size binary (e.g. hashes, UUIDs)
BINARY(16) -- fixed 16 bytes, right-padded with \0
VARBINARY(255)
# store a UUID as binary for compactness
CREATE TABLE sessions (
id BINARY(16) PRIMARY KEY,
data VARBINARY(1000)
);
INSERT INTO sessions (id, data) VALUES (UUID_TO_BIN(UUID()), '...');
# retrieve as text
SELECT BIN_TO_UUID(id) FROM sessions;DML (INSERT / UPDATE / DELETE)
INSERT
Multi-row INSERT is much faster than repeated single-row inserts because it amortizes parsing, network round-trips and index updates. INSERT ... SELECT copies data between tables. The SET syntax is MySQL-specific and convenient for one row. Always list target columns explicitly so the statement survives schema changes. Use INSERT IGNORE or ON DUPLICATE KEY UPDATE for idempotent inserts.
# single row
INSERT INTO users (username, email)
VALUES ('alice', '[email protected]');
# multiple rows in one statement
INSERT INTO users (username, email) VALUES
('bob', '[email protected]'),
('carol', '[email protected]'),
('dave', '[email protected]');
# insert from a SELECT
INSERT INTO archive_users (username, email)
SELECT username, email FROM users WHERE status = 'inactive';
# insert with column-order-free syntax
INSERT INTO users SET username='eve', email='[email protected]';INSERT ... ON DUPLICATE KEY UPDATE (Upsert)
ON DUPLICATE KEY UPDATE implements upsert — when a UNIQUE/PRIMARY KEY conflict occurs, MySQL updates the existing row instead of erroring. VALUES(col) refers to the value that would have been inserted; MySQL 8.0.19+ deprecates this in favor of row aliases (AS new). This is more efficient than a separate SELECT-then-INSERT/UPDATE because it is atomic. INSERT IGNORE silently skips conflicts instead.
CREATE TABLE counters (
name VARCHAR(50) PRIMARY KEY,
hits INT NOT NULL DEFAULT 0
);
# upsert: insert or update on key conflict
INSERT INTO counters (name, hits)
VALUES ('home', 1)
ON DUPLICATE KEY UPDATE hits = hits + 1;
# reference the proposed values with VALUES()
INSERT INTO counters (name, hits) VALUES ('about', 5)
ON DUPLICATE KEY UPDATE hits = VALUES(hits) + 1;
# MySQL 8.0.20+: use an alias for the row being inserted
INSERT INTO counters (name, hits) VALUES ('home', 1) AS new
ON DUPLICATE KEY UPDATE hits = counters.hits + new.hits;UPDATE
Always include a WHERE clause — an UPDATE without one modifies every row. MySQL supports multi-table UPDATE with JOIN, useful for denormalizing computed values. ORDER BY + LIMIT enables safe batched updates that avoid long locks on huge tables. In safe-updates mode the client refuses UPDATE/DELETE without a key in the WHERE clause, a guard against accidental mass changes.
# basic update with WHERE (always include one!)
UPDATE users
SET status = 'banned', email = NULL
WHERE id = 42;
# update with expression
UPDATE products
SET price = price * 1.10
WHERE category = 'electronics';
# multi-table update with JOIN
UPDATE users u
JOIN orders o ON o.user_id = u.id
SET u.total_spent = u.total_spent + o.amount
WHERE o.paid = 1;
# limit and order (useful for batched updates)
UPDATE logs SET archived = 1
WHERE archived = 0
ORDER BY created_at ASC
LIMIT 1000;DELETE
DELETE removes rows one at a time and is transactional (can be rolled back) and fires triggers. Multi-table DELETE can remove from several tables in one statement. For emptying a whole table, TRUNCATE is far faster and resets AUTO_INCREMENT, but is not transactional. The QUICK modifier (MyISAM) skips updating index leaves — rarely needed with InnoDB. Always use a WHERE clause.
# delete specific rows
DELETE FROM users WHERE status = 'banned' AND last_login < '2020-01-01';
# delete with LIMIT (batched)
DELETE FROM logs WHERE created_at < '2023-01-01' LIMIT 5000;
# multi-table delete with JOIN
DELETE u, o
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = 99;
# delete all rows (keeps table & AUTO_INCREMENT, unlike TRUNCATE)
DELETE FROM temp_data;
# quick delete that doesn't return row count
DELETE QUICK FROM logs WHERE year = 2020;REPLACE
REPLACE is MySQL-specific: if a row with the same PK/UNIQUE key exists, it is deleted and a new row inserted; otherwise it just inserts. This is simpler than upsert but has side effects — it fires DELETE then INSERT triggers (not UPDATE), resets AUTO_INCREMENT, and drops any columns not provided to their defaults. Prefer INSERT ... ON DUPLICATE KEY UPDATE for most use cases.
# REPLACE deletes then inserts on key conflict
REPLACE INTO users (id, username, email)
VALUES (1, 'alice', '[email protected]');
# REPLACE ... SET form
REPLACE INTO users SET id = 1, username = 'alice', email = '[email protected]';
# REPLACE from SELECT
REPLACE INTO daily_stats (day, visits)
SELECT CURDATE(), COUNT(*) FROM visits_log;
# caveat: REPLACE fires DELETE + INSERT triggers,
# not UPDATE, and resets columns not listedLOAD DATA INFILE
LOAD DATA INFILE is the fastest way to bulk-load CSV — 20x quicker than INSERT statements because it bypasses SQL parsing. The server reads files from a secure directory (secure_file_priv); LOCAL has the client send the file but is disabled by default for security. Use @var capture columns then SET to transform values during load. SELECT ... INTO OUTFILE is the symmetric export.
# fast bulk import from a CSV file
LOAD DATA INFILE '/var/lib/mysql-files/users.csv'
INTO TABLE users
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(username, email, @created)
SET created_at = STR_TO_DATE(@created, '%Y-%m-%d %H:%i:%s');
# LOCAL reads from the client machine (needs --local-infile)
LOAD DATA LOCAL INFILE 'C:/data/users.csv'
INTO TABLE users
FIELDS TERMINATED BY ','
LINES TERMINATED BY '\r\n'
IGNORE 1 LINES (username, email);
# export counterpart
SELECT * FROM users INTO OUTFILE '/tmp/users.csv'
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n';SELECT Queries
Basic SELECT
Avoid SELECT * in production — it transfers unnecessary data, breaks if columns are added/removed, and prevents the optimizer from using covering indexes. Name the columns you need. LIMIT with OFFSET implements pagination but is slow for large offsets (it still scans skipped rows); keyset pagination (WHERE id > last_id LIMIT n) scales far better.
# select all columns
SELECT * FROM users;
# select specific columns (preferred)
SELECT id, username, email FROM users;
# column aliases
SELECT username AS name, email AS contact FROM users;
# constants and expressions
SELECT 1 + 1 AS sum, NOW() AS now, 'hello' AS greeting;
# limit rows and skip (pagination)
SELECT id, username FROM users
ORDER BY id DESC
LIMIT 10 OFFSET 20;
# MySQL shorthand: LIMIT offset, count
SELECT id, username FROM users LIMIT 20, 10;WHERE & Operators
Comparisons with NULL yield NULL (treated as false), so always use IS NULL / IS NOT NULL. NULL in an IN/NOT IN list can produce surprising empty results — use NOT EXISTS or a correlated subquery instead. BETWEEN is inclusive. MySQL evaluates AND before OR, so parenthesize mixed conditions. For best performance put the most selective condition first and ensure indexed columns are on the left of the operator.
# comparison operators
SELECT * FROM products WHERE price < 100;
SELECT * FROM products WHERE price BETWEEN 50 AND 150;
SELECT * FROM users WHERE id IN (1, 5, 9);
# logical operators
SELECT * FROM users
WHERE status = 'active' AND (age >= 18 OR verified = 1);
# NULL checks (never use = with NULL)
SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;
# BETWEEN is inclusive on both ends
SELECT * FROM orders WHERE created_at
BETWEEN '2024-01-01 00:00:00' AND '2024-01-31 23:59:59';
# NOT IN with NULL traps: NULL poisons the resultORDER BY & LIMIT
ORDER BY sorts results; multiple keys are applied left to right. FIELD() enables custom ordering by an enumerated list. For large result sets ORDER BY ... LIMIT can still scan and sort the whole set unless an index supports it — add an index on the ORDER BY columns. The window-function pattern (ROW_NUMBER OVER PARTITION BY) is the canonical top-N-per-group solution in MySQL 8.0+.
# ascending (default) and descending
SELECT * FROM users ORDER BY created_at;
SELECT * FROM users ORDER BY created_at DESC, username ASC;
# order by expression
SELECT product, price * stock AS value
FROM inventory
ORDER BY value DESC;
# order by FIELD() for custom ordering
SELECT * FROM tasks
ORDER BY FIELD(priority, 'high', 'medium', 'low');
# stable top-N per group via window function (8.0+)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY id DESC) rn
FROM orders
) t WHERE rn <= 3;DISTINCT & Grouping Values
DISTINCT collapses identical rows; with multiple columns it deduplicates the combination. COUNT(DISTINCT col) counts unique non-null values. DISTINCT is essentially GROUP BY over all selected columns. To deduplicate while keeping a representative row (e.g. the newest), use ROW_NUMBER() OVER (PARTITION BY ...). DISTINCT can be expensive — it builds a temporary table, so prefer an index.
# unique values of one column
SELECT DISTINCT country FROM users;
# distinct over multiple columns
SELECT DISTINCT city, country FROM users;
# COUNT distinct values
SELECT COUNT(DISTINCT country) AS countries FROM users;
# GROUP BY returns one row per group; DISTINCT is a special case
SELECT country, COUNT(*) AS users
FROM users
GROUP BY country;
# deduplicate while keeping the latest row (8.0+)
WITH latest AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id DESC) rn
FROM users
)
SELECT id, email FROM latest WHERE rn = 1;LIKE & REGEXP Pattern Matching
LIKE is simple but leading wildcards (LIKE '%x') defeat indexes and force a full scan. Collation decides case sensitivity — utf8mb4_0900_ai_ci is accent/case-insensitive. REGEXP/RLIKE add full regex power but never use an index. MySQL 8.0 adds REGEXP_LIKE, REGEXP_REPLACE, REGEXP_INSTR and REGEXP_SUBSTR for richer pattern handling. For full-text search use a FULLTEXT index with MATCH ... AGAINST.
# LIKE wildcards: % (any chars) and _ (one char)
SELECT * FROM users WHERE username LIKE 'a%'; -- starts with a
SELECT * FROM users WHERE email LIKE '%@gmail.com';
SELECT * FROM users WHERE username LIKE '_an'; -- 3 chars, ends 'an'
# escape a wildcard literally
SELECT * FROM users WHERE path LIKE '50\%' ESCAPE '\\';
# case-insensitive LIKE depends on column collation
# *_ci collations are case-insensitive
# REGEXP / RLIKE for regular expressions
SELECT * FROM users WHERE email REGEXP '^[a-z]+@';
SELECT * FROM products WHERE name RLIKE 'iPhone|Galaxy';
# capture groups with REGEXP_REPLACE (8.0+)
SELECT REGEXP_REPLACE(phone, '([0-9]{3})([0-9]{4})', '$1-$2')
FROM contacts;CASE Expression
CASE is the SQL conditional expression. The searched form evaluates WHEN conditions top to bottom and returns the first match's THEN value, else ELSE (or NULL). CASE can be used in SELECT, WHERE, ORDER BY and aggregates — the SUM(CASE ...) pattern is a classic pivot. CASE never short-circuits aggregation, so every row is counted. IF() and IFNULL() are shorter MySQL shortcuts for simple cases.
# searched CASE (like if/else)
SELECT username,
CASE
WHEN age < 18 THEN 'minor'
WHEN age < 65 THEN 'adult'
ELSE 'senior'
END AS age_group
FROM users;
# simple CASE (compare to a value)
SELECT order_id,
CASE status
WHEN 0 THEN 'pending'
WHEN 1 THEN 'paid'
WHEN 2 THEN 'shipped'
ELSE 'unknown'
END AS status_label
FROM orders;
# CASE in aggregate (pivot)
SELECT
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) AS paid_count,
SUM(CASE WHEN status='shipped' THEN 1 ELSE 0 END) AS shipped_count
FROM orders;JOINs
INNER JOIN
INNER JOIN returns rows only when there is a match in both tables — unmatched rows on either side are dropped. JOIN is shorthand for INNER JOIN. USING (col) is a concise alternative to ON a.col = b.col when the column name is identical and appears once in the result. INNER JOIN is the default and most common join type for relating normalized tables.
# only matching rows from both tables
SELECT u.username, o.id AS order_id, o.total
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE o.paid = 1;
# join three tables
SELECT u.username, o.id, oi.product_id, oi.qty
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id;
# USING clause when join columns share a name
SELECT u.username, o.id
FROM users u
JOIN orders o USING (user_id);LEFT & RIGHT JOIN
LEFT JOIN keeps every left-table row and fills the right side with NULL when there is no match. The WHERE o.id IS NULL trick (anti-join) finds left rows with no matching right row — often clearer and faster than NOT IN, especially with NULLs. RIGHT JOIN is symmetric; most people reorder the tables and use LEFT JOIN for readability. NULLs in the join key never match.
# LEFT JOIN: all left rows, with NULLs where no match
SELECT u.username, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;
# find users with NO orders (anti-join pattern)
SELECT u.username
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
# RIGHT JOIN is the mirror (rarely used; swap tables instead)
SELECT u.username, o.id
FROM orders o
RIGHT JOIN users u ON o.user_id = u.id;CROSS JOIN
CROSS JOIN returns the Cartesian product — every combination of the two tables' rows. It is useful for generating combinations or seeding dimension matrices, but produces N x M rows so it is expensive on large tables. A comma between tables (FROM a, b) is an implicit CROSS JOIN. The recursive CTE pattern is the standard way to generate a sequence/dimension table in MySQL 8.0+.
# Cartesian product: every row of A paired with every row of B
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
# same effect with a comma join
SELECT s.size, c.color FROM sizes s, colors c;
# generate a series of dates (no built-in GENERATE_SERIES)
WITH RECURSIVE nums(n) AS (
SELECT 1 UNION ALL SELECT n+1 FROM nums WHERE n < 7
)
SELECT DATE_ADD('2024-01-01', INTERVAL n-1 DAY) AS day
FROM nums;
# build a matrix of all category x region combinations
SELECT cat.name, reg.name
FROM categories cat CROSS JOIN regions reg;Self Join
A self join references the same table twice with different aliases to relate rows within it — classic for manager/employee or adjacency-list hierarchies. The a.id < b.id condition avoids duplicate symmetric pairs. For deep hierarchies use a recursive CTE (MySQL 8.0+), which walks the tree without a fixed depth limit and can build indented tree output with REPEAT/CONCAT.
# employees and their managers (same table twice)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
# find pairs of users in the same city
SELECT a.username AS user_a, b.username AS user_b, a.city
FROM users a
JOIN users b ON a.city = b.city AND a.id < b.id;
# hierarchical tree with recursive CTE (8.0+)
WITH RECURSIVE org AS (
SELECT id, name, manager_id, 1 AS depth
FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, o.depth + 1
FROM employees e JOIN org o ON e.manager_id = o.id
)
SELECT CONCAT(REPEAT('-- ', depth-1), name) AS tree
FROM org;FULL OUTER JOIN Workaround
MySQL does not implement FULL OUTER JOIN. The standard emulation is a LEFT JOIN UNION a RIGHT JOIN — UNION de-duplicates so each matching row appears once and unmatched rows from either side appear with NULLs on the other. For large results prefer UNION ALL with a deduplication strategy, since UNION's implicit DISTINCT is costly. If you only need unmatched rows, use two anti-joins instead.
# MySQL has no FULL OUTER JOIN — emulate with LEFT + UNION + RIGHT
SELECT u.username, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
UNION
SELECT u.username, o.id AS order_id
FROM users u
RIGHT JOIN orders o ON o.user_id = u.id;
# the UNION removes duplicates; use UNION ALL to keep them
# for an anti-union (mismatches only) add WHERE o.id IS NULL
# or u.id IS NULL in each branchNATURAL JOIN & USING
NATURAL JOIN is fragile — it joins on every shared column name, so adding a column can silently change results; avoid it in production. USING (col) is safer and collapses the join column to one copy in the output. STRAIGHT_JOIN forces the table order the optimizer uses and is a manual override for bad plans; remove it once the real issue is fixed. Prefer explicit ON for clarity.
# NATURAL JOIN matches all same-named columns automatically
SELECT * FROM users NATURAL JOIN profiles;
# USING matches specific shared columns, dedups them in output
SELECT u.username, user_id
FROM users u
JOIN orders o USING (user_id);
# STRAIGHT_JOIN forces the optimizer to join left-to-right
SELECT STRAIGHT_JOIN u.username, o.id
FROM users u
JOIN orders o ON o.user_id = u.id;Aggregation & Grouping
Aggregate Functions
Aggregate functions collapse many rows into one: COUNT, SUM, AVG, MIN, MAX. COUNT(*) counts rows; COUNT(col) counts non-null values of col; COUNT(DISTINCT col) counts unique values. AVG ignores NULLs (not zero) which can skew results — use COALESCE(col,0) if you want NULL treated as zero. Aggregates without GROUP BY return a single row even on an empty table.
SELECT
COUNT(*) AS row_count,
COUNT(email) AS emails, -- ignores NULLs
COUNT(DISTINCT country) AS countries,
MIN(created_at) AS first_user,
MAX(created_at) AS last_user,
AVG(age) AS avg_age,
SUM(balance) AS total_balance
FROM users;
# aggregate over a filtered set
SELECT SUM(amount) AS paid_total
FROM orders
WHERE status = 'paid';GROUP BY
GROUP BY collapses rows sharing the grouped column values into one output row, with aggregates computed per group. MySQL 8.0 enables ONLY_FULL_GROUP_BY by default, which rejects queries that select columns not in GROUP BY (earlier versions returned an arbitrary row — a common bug). Group by expressions or aliases. For reporting time series, group by YEAR()/MONTH()/DATE() of a timestamp.
# one row per country
SELECT country, COUNT(*) AS users, AVG(age) AS avg_age
FROM users
GROUP BY country;
# group by multiple columns
SELECT country, city, COUNT(*) AS users
FROM users
GROUP BY country, city
ORDER BY country, users DESC;
# group by expression
SELECT YEAR(created_at) AS yr, MONTH(created_at) AS m, COUNT(*)
FROM users
GROUP BY yr, m;
# MySQL 8.0 enforces ONLY_FULL_GROUP_BY:
# every non-aggregated SELECT column must be in GROUP BYHAVING
HAVING filters the result of aggregation whereas WHERE filters input rows before grouping — so HAVING can reference aggregates (SUM, COUNT) and WHERE cannot. Put row-level filters in WHERE for efficiency (they reduce the rows before grouping). HAVING accepts aliases in MySQL (HAVING users > 100). A query can use both: WHERE narrows rows, GROUP BY buckets them, HAVING narrows buckets.
# HAVING filters groups (after aggregation); WHERE filters rows (before)
SELECT country, COUNT(*) AS users
FROM users
GROUP BY country
HAVING COUNT(*) > 100
ORDER BY users DESC;
# filter on an aggregate
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 1000;
# combine WHERE and HAVING
SELECT country, COUNT(*) AS users, AVG(age) AS avg_age
FROM users
WHERE status = 'active'
GROUP BY country
HAVING avg_age >= 30;GROUP BY ROLLUP
WITH ROLLUP adds extra super-aggregate rows for each grouping level plus a grand total, where the grouped column becomes NULL. GROUPING(col) returns 1 on a subtotal/total row so you can label NULLs as 'ALL'. ROLLUP works left-to-right on the GROUP BY columns. There is no WITH CUBE in MySQL — emulate with UNION ALL of multiple ROLLUP queries if you need every dimension combination.
# ROLLUP adds subtotals and a grand total row
SELECT country, city, COUNT(*) AS users
FROM users
GROUP BY country, city WITH ROLLUP;
# output includes:
# country | city | users
# 'US' | 'NYC' | 120
# 'US' | NULL | 200 <- US subtotal
# NULL | NULL | 950 <- grand total
# identify super-aggregate rows with GROUPING()
SELECT
IF(GROUPING(country)=1,'ALL',country) AS country,
IF(GROUPING(city)=1,'ALL',city) AS city,
COUNT(*) AS users
FROM users
GROUP BY country, city WITH ROLLUP;GROUP_CONCAT
GROUP_CONCAT is MySQL's string-aggregate function — it concatenates values from a group into a single string. Use DISTINCT to dedupe, ORDER BY to sort, and SEPARATOR to change the delimiter (default ','). The result is capped at group_concat_max_len (default 1024 bytes); raise it for longer lists. Other databases call this LISTAGG/STRING_AGG — GROUP_CONCAT is the MySQL equivalent.
# concatenate grouped values into one string
SELECT country,
GROUP_CONCAT(username) AS all_names
FROM users
GROUP BY country;
# order within the list and deduplicate
SELECT user_id,
GROUP_CONCAT(DISTINCT tag ORDER BY tag SEPARATOR ',') AS tags
FROM post_tags
GROUP BY user_id;
# the default separator is ',', max length is 1024 by default
# raise the limit:
# SET SESSION group_concat_max_len = 1000000;
SELECT department,
GROUP_CONCAT(name ORDER BY salary DESC SEPARATOR ' | ') AS ranking
FROM employees
GROUP BY department;WITH ROLLUP vs Window Aggregates
ROLLUP shrinks the result by adding subtotal rows, whereas a window aggregate (OVER) keeps the same row count and appends the aggregate as an extra column — so you can see both detail and totals together. Use ROLLUP for summary reports and window functions when you need per-row context alongside the aggregate. Running totals are a classic SUM OVER (ORDER BY ...) use case.
# ROLLUP gives fewer rows (subtotals mixed into the result)
SELECT country, city, COUNT(*) AS users
FROM users
GROUP BY country, city WITH ROLLUP;
# a window aggregate keeps every detail row AND adds the total
SELECT
country, city,
COUNT(*) AS users_in_city,
COUNT(*) OVER (PARTITION BY country) AS users_in_country,
COUNT(*) OVER () AS users_total
FROM users;
# running total over time
SELECT created_at, amount,
SUM(amount) OVER (ORDER BY created_at) AS running_total
FROM daily_sales;Subqueries & CTEs
Scalar Subquery
A scalar subquery returns a single value (one row, one column) and can be used anywhere an expression is valid — SELECT list, WHERE, HAVING. If it returns no rows the value is NULL. The optimizer can often turn a correlated scalar subquery into a join (subquery materialization). For clarity and sometimes performance, a CTE or JOIN is preferable when the same subquery is referenced repeatedly.
# returns a single value
SELECT username, balance,
(SELECT AVG(balance) FROM users) AS avg_balance
FROM users
WHERE balance > (SELECT AVG(balance) FROM users);
# use in SELECT list, WHERE, HAVING, etc.
SELECT product, price,
price - (SELECT MIN(price) FROM products) AS above_min
FROM products;
# a scalar subquery must return exactly one row, one columnIN / ANY / ALL Subqueries
IN matches any value returned by the subquery; NOT IN is the complement but is poisoned by NULLs — if the subquery returns a NULL, NOT IN returns no rows at all, so always exclude NULLs or use NOT EXISTS. ALL is true when the comparison holds for every returned value; ANY/SOME when it holds for at least one. The optimizer often rewrites these into semi-joins.
# IN: value matches any row of the subquery
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE status='active');
# NOT IN with NULLs is dangerous — prefer NOT EXISTS
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);
# ANY / SOME / ALL with comparisons
SELECT * FROM products
WHERE price > ALL (SELECT price FROM products WHERE category='toys');
SELECT * FROM products
WHERE price > ANY (SELECT price FROM products WHERE category='toys');EXISTS & NOT EXISTS
EXISTS tests for the existence of rows without returning data — it stops scanning at the first match, so it is efficient for presence checks. The subquery is correlated (references the outer query). NOT EXISTS is the NULL-safe way to find rows without matches and is generally preferred over NOT IN. SELECT 1 inside EXISTS is conventional; the column list is irrelevant. The optimizer often turns EXISTS into a semi-join.
# EXISTS: true if the subquery returns any rows
SELECT u.username
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
# NOT EXISTS: the standard anti-semi-join (NULL-safe)
SELECT u.username
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
# correlated: the subquery references the outer row (u.id)
# EXISTS stops at the first matching row — efficient for presence checksDerived Tables
A derived table is a subquery in the FROM clause — it materializes an intermediate result you can select from or join. Always alias it. MySQL 8.0 can merge many derived tables into the outer query (derived_merge) for better plans, but complex aggregates or LIMIT usually force materialization. For readability and reuse across a query, prefer a named CTE (WITH) over an inline derived table.
# a subquery in the FROM clause is a derived table
SELECT t.country, t.users
FROM (
SELECT country, COUNT(*) AS users
FROM users
GROUP BY country
) t
WHERE t.users > 50;
# join a derived table
SELECT u.username, agg.order_count
FROM users u
JOIN (
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
) agg ON agg.user_id = u.id;
# derived tables must be aliasedCorrelated Subquery
A correlated subquery references a column from the outer query, so it is logically re-evaluated for each outer row — which can be slow on large sets. The optimizer may transform it into a join or use a cache (subquery cache) to mitigate. For rank/top-N problems (e.g. nth-highest salary) a window function like DENSE_RANK() OVER (ORDER BY salary DESC) is clearer and faster — reach for that on MySQL 8.0+.
# the subquery references the outer row (u.id) and runs per row
SELECT u.username,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;
# correlated subquery in WHERE (nth-highest salary classic)
SELECT e1.name, e1.salary
FROM employees e1
WHERE 2 = (
SELECT COUNT(DISTINCT e2.salary)
FROM employees e2
WHERE e2.salary > e1.salary
);
# prefer a window function for rank-style problems in 8.0+Common Table Expressions (CTE)
A CTE (WITH clause) names a subquery for readability and reuse within one statement; MySQL 8.0+ supports it. RECURSIVE enables self-referencing CTEs — essential for tree traversal and sequence generation (UNION ALL to recurse, UNION to dedupe). A recursive CTE needs a base case, a recursive arm, and a termination guard. Multiple CTEs are comma-separated and can reference earlier ones in the same WITH.
# a named CTE (WITH) — readable, can be referenced multiple times
WITH active_users AS (
SELECT id, username FROM users WHERE status = 'active'
)
SELECT au.username, COUNT(o.id) AS orders
FROM active_users au
LEFT JOIN orders o ON o.user_id = au.id
GROUP BY au.username;
# RECURSIVE CTE: walk a hierarchy or generate a series
WITH RECURSIVE nums(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM nums WHERE n < 10
)
SELECT n FROM nums;
# multiple CTEs, comma-separated
WITH a AS (SELECT ...), b AS (SELECT ...)
SELECT * FROM a JOIN b USING (id);Indexes
Create Index
An index is a data structure (B+Tree in InnoDB) that speeds up lookups and sorts at the cost of write overhead and storage. Create indexes on columns used in WHERE, JOIN and ORDER BY. UNIQUE indexes also enforce a uniqueness constraint. Prefix indexes on long text columns save space but cannot be used for covering scans or ORDER BY. Every primary key is clustered in InnoDB — secondary indexes store the PK value as a row pointer.
# single-column index
CREATE INDEX idx_email ON users(email);
# unique index (enforces uniqueness, allows fast lookup)
CREATE UNIQUE INDEX uq_username ON users(username);
# create with a table
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255),
UNIQUE KEY uq_email (email)
);
# full-table prefix index (limited length)
CREATE INDEX idx_name ON users(last_name(20));Composite Index
Composite indexes are ordered by their leftmost column first, so they only help queries that filter on a leftmost prefix of the column list — column order is critical. Put the most selective equality column first, then ranges. A covering index contains every column the query needs, allowing an index-only scan that skips the table lookup entirely (EXPLAIN shows 'Using index'). Don't over-index — every index slows writes.
# multi-column index (column order matters!)
CREATE INDEX idx_last_first ON users(last_name, first_name);
# supports these lookups (leftmost prefix rule):
# WHERE last_name = 'X' -> uses index
# WHERE last_name = 'X' AND first_name = 'Y' -> uses index
# WHERE first_name = 'Y' -> CANNOT use index
# covering index: all needed columns are in the index
CREATE INDEX idx_cover ON orders(user_id, status, amount);
SELECT user_id, SUM(amount) FROM orders
WHERE user_id = 5 AND status = 'paid' GROUP BY user_id;
# ^ index-only scan, no table lookup neededFull-Text Index
FULLTEXT indexes enable natural-language and boolean text search, far better than LIKE '%word%'. MATCH ... AGAINST returns a relevance score. Boolean mode supports operators: +require, -exclude, *wildcard, >increase, ~decrease. Requires InnoDB (5.6+) or MyISAM and a minimum word length (ft_min_word_len / innodb_ft_min_token_size, default 3). For serious search at scale consider a dedicated engine like Elasticsearch.
CREATE TABLE articles (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200),
body TEXT,
FULLTEXT KEY ft_title_body (title, body)
) ENGINE=InnoDB;
# natural-language search
SELECT id, title
FROM articles
WHERE MATCH(title, body) AGAINST ('database performance');
# boolean mode: +require -exclude ~relax
SELECT id, title
FROM articles
WHERE MATCH(title, body)
AGAINST ('+MySQL -Oracle >index' IN BOOLEAN MODE);
# query expansion (second pass adds related terms)
SELECT id FROM articles
WHERE MATCH(title, body) AGAINST ('database' WITH QUERY EXPANSION);Spatial Index
Spatial (R-tree) indexes accelerate geographic queries on GEOMETRY/POINT/POLYGON columns. MySQL 8.0 standardized on SRID 4326 (WGS 84 GPS coordinates) and added ST_Distance_Sphere for real-world meter distances. The column must be NOT NULL. Use ST_Within/ST_Contains/ST_Distance for filtering. Always express coordinates as (longitude, latitude) to match the X/Y convention. Spatial indexes make radius queries vastly faster.
CREATE TABLE places (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
location POINT NOT NULL SRID 4326,
SPATIAL KEY sp_location (location)
) ENGINE=InnoDB;
# insert a point (longitude, latitude)
INSERT INTO places (name, location)
VALUES ('HQ', ST_PointFromText('POINT(116.40 39.90)', 4326));
# find points within a bounding distance
SELECT name, ST_Distance_Sphere(location,
ST_PointFromText('POINT(116.40 39.90)', 4326)) AS meters
FROM places
WHERE ST_Within(location,
ST_Buffer(ST_PointFromText('POINT(116.40 39.90)', 4326), 0.01));Index Management
SHOW INDEX lists a table's indexes with column order and cardinality (distinct-value estimate). DROP INDEX removes an index; renaming (8.0+) avoids a drop+rebuild. OPTIMIZE TABLE rebuilds the table to reclaim fragmented space and refresh index statistics — lock-heavy, so schedule during low traffic. Stale statistics can mislead the optimizer; run ANALYZE TABLE to refresh them after large data loads.
# show indexes on a table
SHOW INDEX FROM users;
# drop an index
DROP INDEX idx_email ON users;
ALTER TABLE users DROP INDEX uq_username;
# rename an index (8.0+)
ALTER TABLE users RENAME INDEX idx_email TO idx_user_email;
# rebuild all indexes on a table
ALTER TABLE users ENGINE=InnoDB;
OPTIMIZE TABLE users;
# check index cardinality
SHOW INDEX FROM users;
SELECT index_name, cardinality
FROM information_schema.statistics
WHERE table_schema='mydb' AND table_name='users';Invisible Indexes & Hints
Invisible indexes (8.0+) let you test removing an index safely — the optimizer ignores it but writes keep it current, so you can flip it back instantly if queries regress; drop it once confident. Optimizer hints (/*+ ... */) are preferred over the old USE/FORCE/IGNORE INDEX syntax and only affect the statement tagged. Use hints sparingly — they are a bandage for bad data distribution or missing statistics.
# make an index invisible (8.0+): optimizer ignores it, still maintained
ALTER TABLE users ALTER INDEX idx_email SET INVISIBLE;
# ... observe query plans, then drop or restore:
ALTER TABLE users ALTER INDEX idx_email SET VISIBLE;
# optimizer hints (8.0+) to force / avoid indexes
SELECT /*+ INDEX(u idx_email) */ * FROM users u WHERE email LIKE 'a%';
SELECT /*+ NO_INDEX(u idx_email) */ * FROM users u;
SELECT /*+ FORCE INDEX(u idx_email) */ * FROM users u;
# index merge / IGNORE_INDEX legacy hint
SELECT * FROM users USE INDEX (idx_email) WHERE email = '[email protected]';
SELECT * FROM users IGNORE INDEX (idx_email) WHERE email = '[email protected]';Views
Create View
A view is a stored query that behaves like a virtual table — it simplifies complex queries, abstracts schema changes and enforces row/column visibility. CREATE OR REPLACE updates the definition in one step. Views do not store data (they re-run the underlying query each time unless materialized) so they add no write cost but no read speedup either. Views are merged into the query when possible.
# a view is a stored SELECT
CREATE VIEW active_users AS
SELECT id, username, email
FROM users
WHERE status = 'active';
# query it like a table
SELECT * FROM active_users WHERE username LIKE 'a%';
# create or replace, with explicit columns
CREATE OR REPLACE VIEW user_summary (username, order_count, total) AS
SELECT u.username, COUNT(o.id), COALESCE(SUM(o.amount), 0)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.username;
# list and inspect views
SHOW FULL TABLES WHERE Table_type = 'VIEW';
SHOW CREATE VIEW active_users\GUpdatable Views
A view over a single base table with no aggregates/distinct is updatable — INSERT/UPDATE/DELETE propagate to the underlying table. By default an UPDATE that moves a row outside the view's WHERE clause succeeds (the row just vanishes from the view); WITH CHECK OPTION blocks such changes so rows can never leave the view's scope. Views with JOINs, GROUP BY, DISTINCT or subqueries are generally not updatable.
# a view over a single base table is updatable
CREATE VIEW active_users AS
SELECT id, username, email, status
FROM users
WHERE status = 'active';
# INSERT through the view inserts into the base table
INSERT INTO active_users (id, username, email, status)
VALUES (10, 'newbie', '[email protected]', 'active');
# UPDATE through the view only touches visible rows
UPDATE active_users SET email = '[email protected]' WHERE id = 10;
# WITH CHECK OPTION prevents inserts/updates that leave the view
CREATE OR REPLACE VIEW active_users AS
SELECT id, username, email, status FROM users WHERE status='active'
WITH CHECK OPTION;View CHECK OPTION
CHECK OPTION enforces that rows changed through a view stay visible to that view. LOCAL checks only the view's own WHERE; CASCADED (the default) also enforces WHERE clauses of any underlying views it references. Use CASCADED when stacking views to keep the whole chain consistent; use LOCAL only when you deliberately want to relax an underlying view's filter.
# LOCAL: only the defining view's WHERE is enforced
CREATE VIEW v_paid AS
SELECT * FROM orders WHERE status='paid' WITH LOCAL CHECK OPTION;
# CASCADED (default): all underlying views' checks are enforced too
CREATE VIEW v_paid_large AS
SELECT * FROM v_paid WHERE amount > 100
WITH CASCADED CHECK OPTION;
# an insert that violates v_paid's filter:
INSERT INTO v_paid_large (id, status, amount) VALUES (1, 'unpaid', 200);
# CASCADED -> rejected (status would leave v_paid)
# LOCAL -> accepted (only v_paid_large's filter checked)Manage Views
ALTER VIEW redefines an existing view without dropping it (preserving grants). DROP VIEW IF EXISTS avoids an error when the view is absent. RENAME TABLE also works for views but is uncommon. information_schema.views exposes each view's definition, whether it is updatable, and security type — useful for auditing. A view depends on its base tables; dropping a base table leaves the view invalid until recreated.
# alter a view's definition (CREATE OR REPLACE is simpler)
ALTER VIEW active_users AS
SELECT id, username FROM users WHERE status='active';
# drop a view
DROP VIEW IF EXISTS active_users;
# drop multiple views
DROP VIEW IF EXISTS v1, v2, v3;
# rename a view (no RENAME VIEW; recreate or use a table rename trick)
RENAME TABLE old_view TO new_view;
# view metadata
SELECT table_name, view_definition, is_updatable
FROM information_schema.views
WHERE table_schema = 'mydb';View Security & Determinism
SQL SECURITY DEFINER runs the view with the definer's privileges — useful to expose filtered data to users who lack base-table access (e.g. a row-level security view). INVOKER checks the caller's privileges instead. ALGORITHM MERGE folds the view into the query (faster, allows updatable views); TEMPTABLE materializes it first (needed for some constructs but loses updatability and can be slower). UNDEFINED lets MySQL choose.
# DEFINER (default): runs with the view creator's privileges
CREATE VIEW secret_emails SQL SECURITY DEFINER AS
SELECT email FROM users;
# INVOKER: runs with the querying user's privileges
CREATE VIEW my_emails SQL SECURITY INVOKER AS
SELECT email FROM users WHERE id = CURRENT_USER_ID();
# ALGORITHM: MERGE (preferred) vs TEMPTABLE vs UNDEFINED
CREATE ALGORITHM=MERGE VIEW v_active AS
SELECT * FROM users WHERE status='active';Stored Procedures
Create & Call Procedure
A stored procedure is a named, precompiled block of SQL. Because procedures can contain semicolons, you change the DELIMITER so the whole CREATE runs as one statement, then reset it. CALL executes a procedure. Procedures do not return a value directly but can return result sets and modify OUT parameters. They run server-side, cutting network round-trips for multi-step logic, but are harder to version and test than application code.
DELIMITER //
CREATE PROCEDURE get_user_by_id(IN p_id INT)
BEGIN
SELECT id, username, email FROM users WHERE id = p_id;
END //
DELIMITER ;
# call it
CALL get_user_by_id(42);
# drop it
DROP PROCEDURE IF EXISTS get_user_by_id;
# show the definition
SHOW CREATE PROCEDURE get_user_by_id\GIN / OUT / INOUT Parameters
Parameters are IN (read-only input), OUT (written back to the caller via a session variable) or INOUT (both). SELECT ... INTO assigns a scalar query result to a variable. The example wraps a debit/credit in a transaction with SELECT FOR UPDATE to lock rows and prevent lost updates. Use OUT parameters to return status codes; result sets are returned directly when you SELECT inside the procedure.
DELIMITER //
CREATE PROCEDURE transfer(
IN p_from INT,
IN p_to INT,
IN p_amount DECIMAL(10,2),
OUT p_result VARCHAR(50)
)
BEGIN
DECLARE v_bal DECIMAL(10,2);
START TRANSACTION;
SELECT balance INTO v_bal FROM accounts WHERE id = p_from FOR UPDATE;
IF v_bal >= p_amount THEN
UPDATE accounts SET balance = balance - p_amount WHERE id = p_from;
UPDATE accounts SET balance = balance + p_amount WHERE id = p_to;
COMMIT;
SET p_result = 'OK';
ELSE
ROLLBACK;
SET p_result = 'INSUFFICIENT_FUNDS';
END IF;
END //
DELIMITER ;
# call with a session variable for the OUT value
CALL transfer(1, 2, 50.00, @res);
SELECT @res;Variables & Control Flow
Stored programs support local variables (DECLARE, scoped to BEGIN...END), user variables (@var, session-scoped) and system variables. Control flow includes IF/ELSEIF/ELSE, CASE, and three loops: LOOP (with LEAVE/ITERATE), WHILE (condition first) and REPEAT (condition last). Always name loops when nesting so LEAVE targets the right one. SET assigns a value; SELECT ... INTO copies a query scalar into a variable.
DELIMITER //
CREATE PROCEDURE classify(IN p_age INT, OUT p_label VARCHAR(20))
BEGIN
DECLARE v_count INT DEFAULT 0;
# IF / ELSEIF / ELSE
IF p_age < 13 THEN
SET p_label = 'child';
ELSEIF p_age < 20 THEN
SET p_label = 'teen';
ELSE
SET p_label = 'adult';
END IF;
# simple loop with a counter
simple_loop: LOOP
SET v_count = v_count + 1;
IF v_count >= 5 THEN LEAVE simple_loop; END IF;
END LOOP;
# WHILE / REPEAT alternatives
# WHILE cond DO ... END WHILE;
# REPEAT ... UNTIL cond END REPEAT;
# CASE statement
CASE p_label
WHEN 'child' THEN SET p_label = CONCAT(p_label, '!');
ELSE SET p_label = CONCAT(p_label, '.');
END CASE;
END //
DELIMITER ;Cursors
A cursor lets a stored procedure iterate row by row over a result set. Declare the cursor first, then a CONTINUE HANDLER FOR NOT FOUND to detect exhaustion (set a flag and LEAVE the loop). FETCH retrieves one row into variables. Cursors are read-only and forward-only in MySQL. Row-by-row processing is slow compared to set-based SQL — prefer a single UPDATE/INSERT ... SELECT when possible; use cursors only when logic is genuinely sequential.
DELIMITER //
CREATE PROCEDURE log_all_users()
BEGIN
DECLARE v_id INT;
DECLARE v_name VARCHAR(50);
DECLARE done INT DEFAULT 0;
# declare the cursor BEFORE handlers
DECLARE cur CURSOR FOR
SELECT id, username FROM users WHERE status='active';
# continue handler for end of cursor -> set done=1
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_id, v_name;
IF done THEN LEAVE read_loop; END IF;
INSERT INTO audit_log(user_id, action) VALUES (v_id, CONCAT('seen:', v_name));
END LOOP;
CLOSE cur;
END //
DELIMITER ;
CALL log_all_users();Error Handlers
Handlers catch errors inside stored programs: CONTINUE runs the handler body then resumes after the failing statement; EXIT runs the body and exits the BEGIN...END block; UNDO rolls back (deprecated). Catch SQLEXCEPTION for any error, SQLWARNING for warnings, or specific SQLSTATE/errno (e.g. 1062 duplicate key). RESIGNAL re-raises the current error after cleanup. Named conditions (CONDITION FOR) make handlers self-documenting.
DELIMITER //
CREATE PROCEDURE safe_insert(IN p_email VARCHAR(255))
BEGIN
# SQLEXCEPTION catches any error; SQLSTATE/errno catch specific ones
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL; -- re-raise so the caller sees the error
END;
DECLARE CONTINUE HANDLER FOR 1062 -- duplicate key
BEGIN
SELECT 'Duplicate skipped' AS msg;
END;
START TRANSACTION;
INSERT INTO users (username, email) VALUES ('x', p_email);
INSERT INTO stats (email) VALUES (p_email);
COMMIT;
END //
DELIMITER ;
# named conditions make handlers readable
DECLARE dup_key CONDITION FOR 1062;
DECLARE CONTINUE HANDLER FOR dup_key SELECT 'dup';Procedure Metadata
SHOW PROCEDURE STATUS lists procedures; SHOW CREATE PROCEDURE returns the full body. information_schema.routines gives richer metadata including determinism and security type. Mark a function DETERMINISTIC (READS SQL DATA / NO SQL) if its output depends only on its inputs — required for binary logging and replication of functions. Procedures do not need the deterministic flag. Use these views to audit and inventory routines.
# list procedures in a database
SHOW PROCEDURE STATUS WHERE Db = 'mydb';
# show definition
SHOW CREATE PROCEDURE get_user_by_id\G
# query information_schema for routines
SELECT routine_name, routine_type, created, last_altered,
is_deterministic, security_type
FROM information_schema.routines
WHERE routine_schema = 'mydb';
# list stored functions too
SELECT routine_name, routine_type
FROM information_schema.routines
WHERE routine_schema = 'mydb' AND routine_type='FUNCTION';Functions
String Functions
LENGTH counts bytes, CHAR_LENGTH counts characters (important for multibyte utf8mb4). SUBSTRING is 1-indexed; negative start counts from the end. CONCAT returns NULL if any argument is NULL — use CONCAT_WS to skip NULLs. SUBSTRING_INDEX splits on a delimiter and keeps the first N parts (negative N keeps from the right). TRIM has BOTH/LEADING/TRAILING variants and can trim any character, not just spaces.
# length & case
SELECT LENGTH('hello'), CHAR_LENGTH('héllo'); -- bytes vs chars
SELECT UPPER('abc'), LOWER('ABC');
# substring & position
SELECT SUBSTRING('MySQL', 3); -- 'SQL'
SELECT SUBSTRING('MySQL', 1, 2); -- 'My'
SELECT INSTR('MySQL', 'SQL'); -- 3
SELECT LOCATE('SQL', 'MySQL', 1); -- 3
# trim & pad
SELECT TRIM(' hi '), LTRIM(' hi'), RTRIM('hi ');
SELECT LPAD('5', 3, '0'), RPAD('5', 3, '-'); -- 005, 5--
# split & replace
SELECT REPLACE('a-b-c', '-', '/');
SELECT SUBSTRING_INDEX('a,b,c', ',', 2); -- 'a,b'
# concatenate with NULL-safe separator
SELECT CONCAT_WS(',', 'a', NULL, 'b'); -- 'a,b'Numeric Functions
ROUND rounds half away from zero; TRUNCATE cuts digits without rounding. MOD and % are interchangeable. RAND() returns a float in [0,1); ORDER BY RAND() is a convenient but expensive way to sample rows because it sorts the whole result set — for large tables use a random PK range or reservoir sampling instead. FORMAT adds thousand separators and rounds to the given decimals, returning a string.
# rounding
SELECT ROUND(2.567, 1); -- 2.6
SELECT CEIL(2.1), FLOOR(2.9); -- 3, 2
SELECT TRUNCATE(2.567, 1); -- 2.5
# modular & power
SELECT MOD(17, 5), 17 % 5; -- 2, 2
SELECT POWER(2, 10); -- 1024
SELECT SQRT(16); -- 4
# random
SELECT RAND(); -- 0..1 float
SELECT FLOOR(RAND() * 100); -- 0..99 integer
SELECT * FROM users ORDER BY RAND() LIMIT 5; -- random sample (slow!)
# formatting
SELECT FORMAT(1234567.891, 2); -- '1,234,567.89'
SELECT ABS(-5), SIGN(-5); -- 5, -1Date & Time Functions
NOW() returns the current date/time as one call (consistent across a statement); CURDATE/CURTIME return date/time parts. DATE_ADD/SUB with INTERVAL is the canonical date arithmetic — it handles month/year rollovers correctly. DATEDIFF returns days (date1 - date2); TIMEDIFF returns a TIME. DATE_FORMAT and STR_TO_DATE use %Y %m %d %H %i %s specifiers (note lowercase i for minutes, s for seconds).
# current values
SELECT NOW(), CURDATE(), CURTIME(), UTC_TIMESTAMP();
# parts of a date
SELECT YEAR(NOW()), MONTH(NOW()), DAY(NOW()), DAYNAME(NOW());
SELECT WEEK(NOW()), WEEKDAY(NOW()), DAYOFWEEK(NOW());
# arithmetic
SELECT DATE_ADD(NOW(), INTERVAL 7 DAY);
SELECT DATE_SUB('2024-01-31', INTERVAL 1 MONTH); -- 2023-12-31
SELECT DATEDIFF('2024-12-31', '2024-01-01'); -- 365 days
SELECT TIMEDIFF('18:00:00', '09:30:00'); -- 08:30:00
# formatting
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s');
SELECT STR_TO_DATE('31/12/2024', '%d/%m/%Y');Custom Functions
A stored function returns a single scalar value and can be used inside expressions (unlike procedures, which you CALL). It must declare characteristics: DETERMINISTIC (same input always yields same output, required for statement-based replication), and one of READS SQL DATA / NO SQL / MODIFIES SQL DATA. Functions cannot return result sets or use transaction control. The NO SQL + DETERMINISTIC combo is safest for pure computation.
DELIMITER //
CREATE FUNCTION full_name(p_first VARCHAR(50), p_last VARCHAR(50))
RETURNS VARCHAR(101)
DETERMINISTIC
READS SQL DATA
BEGIN
RETURN CONCAT_WS(' ', p_first, p_last);
END //
DELIMITER ;
# use it like a built-in
SELECT id, full_name(first_name, last_name) FROM users;
# drop it
DROP FUNCTION IF EXISTS full_name;
# functions MUST declare their nature for binary logging:
# DETERMINISTIC - same inputs -> same output (required for replication)
# READS SQL DATA - reads but doesn't modify
# NO SQL - no SQL at allControl Flow Functions
IF() is a compact ternary; IFNULL(a,b) returns a unless a is NULL then b (two-arg only); COALESCE generalizes to N arguments. NULLIF(a,b) returns NULL when a=b, handy for avoiding division by zero (x / NULLIF(y,0)). ISNULL() returns 1/0, not the value — do not confuse it with IFNULL. CASE is the most flexible and portable conditional expression and works in any SQL dialect.