Skip to content

MySQL Spickzettel

Popular open-source relational database management system.

01

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.

mysql
# 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.

mysql
# 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.

mysql
# 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\G

Server 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.

mysql
# 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.

mysql
-- 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\G

Configuration 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.

mysql
# /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]>\_
02

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.

mysql
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.

mysql
# 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.

mysql
# 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.

mysql
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'\G

Table 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.

mysql
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.

mysql
# 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;
03

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.

mysql
# 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 UNIQUE

String & 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.

mysql
# 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 utf8mb4

Date & 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';.

mysql
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 range

ENUM & 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.

mysql
# 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.

mysql
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.

mysql
# 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;
04

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.

mysql
# 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.

mysql
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.

mysql
# 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.

mysql
# 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.

mysql
# 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 listed

LOAD 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.

mysql
# 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';
05

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.

mysql
# 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.

mysql
# 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 result

ORDER 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+.

mysql
# 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.

mysql
# 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.

mysql
# 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.

mysql
# 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;
06

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.

mysql
# 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.

mysql
# 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+.

mysql
# 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.

mysql
# 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
# 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 branch

NATURAL 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.

mysql
# 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;
07

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.

mysql
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.

mysql
# 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 BY

HAVING

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.

mysql
# 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.

mysql
# 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.

mysql
# 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.

mysql
# 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;
08

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.

mysql
# 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 column

IN / 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.

mysql
# 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.

mysql
# 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 checks

Derived 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.

mysql
# 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 aliased

Correlated 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+.

mysql
# 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.

mysql
# 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);
09

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.

mysql
# 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.

mysql
# 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 needed

Full-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.

mysql
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.

mysql
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.

mysql
# 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.

mysql
# 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]';
10

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.

mysql
# 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\G

Updatable 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.

mysql
# 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.

mysql
# 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.

mysql
# 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.

mysql
# 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';
11

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.

mysql
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\G

IN / 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.

mysql
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.

mysql
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.

mysql
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.

mysql
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.

mysql
# 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';
12

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.

mysql
# 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.

mysql
# 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, -1

Date & 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).

mysql
# 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.

mysql
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 all

Control 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.

mysql
# IF(test, true_val, false_val) - ternary
SELECT IF(age >= 18, 'adult', 'minor') FROM users;

# IFNULL / NULLIF
SELECT IFNULL(email, 'no-email') FROM users;          -- coalesce one
SELECT COALESCE(nick, email, username) FROM users;    -- coalesce many
SELECT NULLIF(status, 'inactive');  -- NULL if equal, else status

# CASE as a function (see SELECT section for full CASE)
SELECT CASE WHEN x>0 THEN 'pos' WHEN x<0 THEN 'neg' ELSE 'zero' END;

# ISNULL() returns 1/0 (different from IFNULL!)
SELECT ISNULL(email) FROM users;

Cast & Conversion

CAST and CONVERT change a value's type — essential when MySQL would otherwise do implicit conversion that defeats indexes (e.g. comparing a string to a number column). CAST(... AS DATE/DATETIME/SIGNED/UNSIGNED/CHAR/DECIMAL) covers common needs. Beware: CAST('123abc' AS SIGNED) returns 123 with a warning rather than erroring. Use STR_TO_DATE for parsing custom date formats, since CAST expects a strict ISO format.

mysql
# CAST to a specific type
SELECT CAST('2024-01-15' AS DATE);
SELECT CAST(3.14 AS SIGNED);          -- 3
SELECT CAST('123abc' AS SIGNED);      -- 123 (warns in strict mode)
SELECT CAST(123 AS CHAR(10));

# CONVERT is the same thing with different syntax
SELECT CONVERT('2024-01-15', DATE);
SELECT CONVERT('hello' USING utf8mb4);  -- charset conversion

# common: string -> date for comparison
SELECT * FROM orders
WHERE created_at >= CAST('2024-01-01' AS DATETIME);

# format a number as a zero-padded string
SELECT LPAD(CAST(id AS CHAR), 6, '0') FROM orders;
13

Triggers

BEFORE / AFTER Triggers

A trigger fires automatically on INSERT/UPDATE/DELETE, either BEFORE (can modify NEW values, validate and abort) or AFTER (for side effects like logging). FOR EACH ROW means the body runs once per affected row — row triggers are the only kind MySQL supports. BEFORE triggers are good for defaulting/normalizing data; AFTER triggers for cascading non-critical work. Triggers run in the same transaction as the firing statement.

mysql
DELIMITER //
CREATE TRIGGER trg_users_before_insert
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
  # normalize email to lowercase before saving
  SET NEW.email = LOWER(NEW.email);
  IF NEW.created_at IS NULL THEN
    SET NEW.created_at = NOW();
  END IF;
END //
DELIMITER ;

# AFTER INSERT trigger logs the event
CREATE TRIGGER trg_users_after_insert
AFTER INSERT ON users
FOR EACH ROW
INSERT INTO audit_log(table_name, row_id, action, at)
VALUES ('users', NEW.id, 'INSERT', NOW());

Audit Trigger (UPDATE)

A common use of triggers is auditing changes — capture OLD vs NEW into a history table. Comparing values must account for NULLs (OLD.email <> NEW.email is NULL if either side is NULL), so guard with IS NULL checks. AFTER DELETE uses OLD (there is no NEW on delete). Keep audit logic lightweight since it runs on every row change. Remember triggers fire for bulk UPDATE/DELETE too, which can be slow on large operations.

mysql
DELIMITER //
CREATE TRIGGER trg_users_after_update
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
  IF OLD.email <> NEW.email OR (OLD.email IS NULL) <> (NEW.email IS NULL) THEN
    INSERT INTO audit_log(table_name, row_id, field, old_val, new_val, at)
    VALUES ('users', NEW.id, 'email', OLD.email, NEW.email, NOW());
  END IF;
END //
DELIMITER ;

# a DELETE audit trigger
CREATE TRIGGER trg_users_after_delete
AFTER DELETE ON users
FOR EACH ROW
INSERT INTO archive_users(id, username, email, deleted_at)
VALUES (OLD.id, OLD.username, OLD.email, NOW());

OLD and NEW Values

NEW holds the incoming row values, OLD the previous ones. You can modify NEW in BEFORE INSERT/UPDATE to normalize or default data; AFTER triggers and DELETE see them read-only. SIGNAL SQLSTATE '45000' is the standard way to raise a custom error from a trigger or stored program (45000 means 'unhandled user-defined exception'). Use BEFORE triggers for validation that should abort the change; AFTER for side effects.

mysql
# OLD: the row before the change (available on UPDATE/DELETE)
# NEW: the row after the change  (available on INSERT/UPDATE)

# BEFORE INSERT: only NEW, you can modify it
# AFTER  INSERT: only NEW, read-only
# BEFORE UPDATE: both OLD and NEW, you can modify NEW
# AFTER  UPDATE: both OLD and NEW, read-only
# BEFORE DELETE: only OLD, read-only
# AFTER  DELETE: only OLD, read-only

CREATE TRIGGER trg_balance_check
BEFORE UPDATE ON accounts
FOR EACH ROW
BEGIN
  IF NEW.balance < 0 THEN
    SIGNAL SQLSTATE '45000'
      SET MESSAGE_TEXT = 'Balance cannot be negative';
  END IF;
END;

Trigger on Multiple Events (FOLLOWS/PRECEDES)

Since 5.7.2 a table can have more than one trigger for the same event and timing, ordered with FOLLOWS/PRECEDES. Each trigger still handles exactly one event (INSERT, UPDATE or DELETE) at one timing (BEFORE/AFTER) — to handle several events you create multiple triggers. Order matters when triggers depend on each other's effects. There is no INSTEAD OF trigger in MySQL (views are updatable directly).

mysql
# MySQL allows multiple triggers with the same event/timing on one table (5.7.2+)
# order them with FOLLOWS / PRECEDES

DELIMITER //
CREATE TRIGGER trg_users_after_insert_2
AFTER INSERT ON users
FOR EACH ROW
FOLLOWS trg_users_after_insert
BEGIN
  UPDATE user_stats SET total = total + 1 WHERE user_id = NEW.id;
END //
DELIMITER ;

# events: INSERT | UPDATE | DELETE
# timing: BEFORE | AFTER
# a trigger cannot span multiple events; create one per event

Manage Triggers

SHOW TRIGGERS lists all triggers (note the backtick-quoting of the 'Table' column name since it is reserved). SHOW CREATE TRIGGER returns the full body and the definer. information_schema.triggers gives structured metadata for auditing. DROP TRIGGER IF EXISTS is safe for scripts. Triggers are tied to the table — dropping the table drops its triggers. Watch the definer: like procedures, triggers run with definer privileges by default.

mysql
# list triggers
SHOW TRIGGERS\G
SHOW TRIGGERS WHERE `Table` = 'users';

# show definition
SHOW CREATE TRIGGER trg_users_before_insert\G

# query metadata
SELECT trigger_name, event_manipulation, action_timing,
       event_object_table, created
FROM information_schema.triggers
WHERE trigger_schema = 'mydb';

# drop a trigger
DROP TRIGGER IF EXISTS trg_users_before_insert;

# triggers fire on the table's schema; definer is recorded
14

Transactions & Locks

Transaction Control

MySQL autocommits by default — each statement is its own transaction. START TRANSACTION/BEGIN starts an explicit block ended by COMMIT (save) or ROLLBACK (undo). Only transactional engines (InnoDB) support transactions; MyISAM does not. Crucially, DDL statements (CREATE/ALTER/DROP/TRUNCATE) implicitly commit the current transaction and cannot be rolled back, so do not mix DDL with transactional DML expecting atomicity.

mysql
# explicit transaction (autocommit is on by default)
START TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;   -- or ROLLBACK;

# alternative syntax
BEGIN;
  INSERT INTO orders (user_id, amount) VALUES (1, 50);
  UPDATE users SET balance = balance - 50 WHERE id = 1;
COMMIT;

# turn autocommit off for the session
SET autocommit = 0;
-- every statement is now part of a transaction until COMMIT/ROLLBACK
SET autocommit = 1;

# DDL (CREATE/ALTER/DROP) implicitly commits — cannot be rolled back

Savepoints

SAVEPOINT marks a named point inside a transaction you can partially roll back to without aborting the whole transaction. ROLLBACK TO SAVEPOINT undoes work after the savepoint; RELEASE SAVEPOINT discards the marker (the work stays). Savepoints let you try an optional step and recover gracefully. They are not visible outside the transaction and disappear on COMMIT/ROLLBACK. Nested savepoints are supported.

mysql
START TRANSACTION;
  INSERT INTO orders (amount) VALUES (10);
  SAVEPOINT sp_after_order;

  INSERT INTO order_items (order_id) VALUES (LAST_INSERT_ID());
  SAVEPOINT sp_after_items;

  -- something risky
  UPDATE inventory SET stock = stock - 1 WHERE sku='X';
  -- undo just the last step, keep the order + items
  ROLLBACK TO SAVEPOINT sp_after_items;

  -- release a savepoint when no longer needed
  RELEASE SAVEPOINT sp_after_order;
COMMIT;

Isolation Levels

Isolation level trades consistency for concurrency. MySQL's default REPEATABLE READ prevents dirty and non-repeatable reads and, unlike the SQL standard, also avoids most phantom reads via next-key locking. READ COMMITTED is popular for lower locking but allows non-repeatable reads. SERIALIZABLE turns all reads into locking reads. InnoDB uses MVCC so readers don't block writers at any level except SERIALIZABLE.

mysql
# four levels, from least to most strict
# READ UNCOMMITTED  - dirty reads possible (rarely used)
# READ COMMITTED    - no dirty reads; non-repeatable reads possible
# REPEATABLE READ   - default; consistent reads within a transaction
# SERIALIZABLE      - locks everything; safest, slowest

# set for the session
SET SESSION transaction_isolation = 'REPEATABLE-READ';
# MySQL 8.0+ name; older: tx_isolation

# set globally (new connections inherit it)
SET GLOBAL transaction_isolation = 'READ-COMMITTED';

# inspect
SELECT @@transaction_isolation;
SELECT @@GLOBAL.transaction_isolation;

LOCK TABLES

LOCK TABLES takes table-level locks and is mostly a MyISAM-era feature — InnoDB's row-level locking is usually preferable. A READ lock lets other sessions read but blocks writes; a WRITE lock blocks everyone else. While holding locks you can only access the locked tables. LOCK TABLES implicitly commits any active transaction, so it does not compose with transactions. Prefer SELECT ... FOR UPDATE for InnoDB concurrency control.

mysql
# explicit table locks (outside transactions; MyISAM-style)
LOCK TABLES users WRITE, orders READ;
  -- only these tables are accessible; users is write-locked
  SELECT * FROM users;
UNLOCK TABLES;

# READ  lock: this session can read; others can read but not write
# WRITE lock: only this session can read/write; others blocked entirely

# in InnoDB prefer row-level locks (SELECT FOR UPDATE) over LOCK TABLES
# LOCK TABLES implicitly commits the active transaction

SELECT ... FOR UPDATE / SHARE

SELECT ... FOR UPDATE takes an exclusive row lock so other transactions block if they try to update or lock the same row — essential for read-modify-write sequences like transfers. FOR SHARE takes a shared lock (others can read but not write). Locks release at COMMIT/ROLLBACK. 8.0 adds NOWAIT (error instead of waiting) and SKIP LOCKED (skip locked rows) — perfect for job-queue workers that should not block each other.

mysql
START TRANSACTION;
  # lock the row so other transactions cannot modify it until commit
  SELECT balance INTO @b FROM accounts WHERE id = 1 FOR UPDATE;

  IF @b >= 100 THEN
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  END IF;
COMMIT;   -- releases the lock

# FOR UPDATE: exclusive lock (write intent)
# FOR SHARE:  shared lock (read intent, others can read)

# NOWAIT / SKIP LOCKED reduce waiting (8.0+)
SELECT * FROM jobs WHERE status='pending' LIMIT 1 FOR UPDATE SKIP LOCKED;

Deadlocks & Detection

InnoDB detects deadlocks automatically and rolls back one transaction (the victim) so the other can proceed — you get error 1213. SHOW ENGINE INNODB STATUS shows the last deadlock; enable innodb_print_all_deadlocks to log every one. Prevent deadlocks by locking rows in a consistent order across transactions, keeping transactions short, and indexing the columns you lock (otherwise locks escalate to large ranges). Always write retry logic around transactions that can deadlock.

mysql
# deadlock: two transactions each hold a lock the other needs
# Tx1: locks A, wants B
# Tx2: locks B, wants A
# -> InnoDB detects it and rolls back the victim automatically

# see the last deadlock
SHOW ENGINE INNODB STATUS\G

# log all deadlocks to the error log
SET GLOBAL innodb_print_all_deadlocks = 1;

# mitigate by:
#  - locking tables/rows in a consistent order across transactions
#  - keeping transactions short
#  - adding appropriate indexes (avoid gap locks on full scans)
#  - using a lower isolation level where safe
15

User Management

Create User & Authentication

A MySQL account is always 'user'@'host' — the host controls where the user can connect from; '%' is a wildcard for any host. MySQL 8.0 defaults to caching_sha2_password (more secure) which some older clients don't support — fall back to mysql_native_password for them. Prefer narrowly-scoped hosts (10.0.0.%) over '%'. CREATE USER is preferred over the old GRANT ... IDENTIFIED BY syntax, which is removed in 8.0.

mysql
# create a user that can connect from anywhere
CREATE USER 'app'@'%' IDENTIFIED BY 'StrongPass!2024';

# restrict to localhost only
CREATE USER 'admin'@'localhost' IDENTIFIED BY 'localpass';

# MySQL 8.0 default plugin is caching_sha2_password
CREATE USER 'dev'@'10.0.%.%' IDENTIFIED WITH caching_sha2_password
  BY 'devpass';

# legacy mysql_native_password (needed for some older clients)
CREATE USER 'legacy'@'%' IDENTIFIED WITH mysql_native_password
  BY 'legacypass';

# rename or change host
RENAME USER 'app'@'%' TO 'appuser'@'10.0.0.%';

# drop a user
DROP USER IF EXISTS 'legacy'@'%';

GRANT Privileges

GRANT assigns privileges at four scopes: global (*.*), database (db.*), table (db.table), and column/routine. ALL PRIVILEGES is everything except GRANT OPTION. Use the least privilege needed — a reporting user should only get SELECT. WITH GRANT OPTION lets the user give their privileges to others (dangerous). FLUSH PRIVILEGES is rarely needed since GRANT updates the in-memory tables; use it only after manually editing the mysql tables.

mysql
# grant all on a specific database
GRANT ALL PRIVILEGES ON mydb.* TO 'app'@'%';

# grant read-only on one table
GRANT SELECT ON mydb.users TO 'reporting'@'%';

# grant specific privileges
GRANT SELECT, INSERT, UPDATE ON mydb.* TO 'editor'@'localhost';

# grant the ability to grant to others (WITH GRANT OPTION)
GRANT SELECT ON mydb.* TO 'lead'@'%' WITH GRANT OPTION;

# grant at server level (use sparingly)
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'monitor'@'10.0.0.5';

# reload privilege tables into memory (usually automatic)
FLUSH PRIVILEGES;

REVOKE Privileges

REVOKE removes privileges previously granted; you must match the scope exactly. To strip everything including GRANT OPTION, use REVOKE ALL PRIVILEGES, GRANT OPTION. Revoking does not drop the user — the account still exists and can connect but has no privileges until granted again. Use SHOW GRANTS to inspect a user's effective privileges; ALWAYS review before changing access. Removing a user entirely is DROP USER.

mysql
# remove specific privileges
REVOKE INSERT, UPDATE ON mydb.* FROM 'editor'@'localhost';

# remove all privileges (but keep the user)
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'%';

# revoke at a specific scope
REVOKE SELECT ON mydb.users FROM 'reporting'@'%';

# see what a user currently has
SHOW GRANTS FOR 'app'@'%';

# show grants for the current user
SHOW GRANTS;
SHOW GRANTS FOR CURRENT_USER();

Roles (MySQL 8.0+)

Roles (8.0+) let you group privileges and assign them to many users at once, simplifying access management. A user granted a role does not get its privileges until the role is activated — SET DEFAULT ROLE makes it active by default on login. CURRENT_ROLE() shows active roles. Change a role once and every member user inherits the change. This is far cleaner than granting the same privileges to each user individually.

mysql
# a role is a named bundle of privileges
CREATE ROLE 'app_read', 'app_write';

GRANT SELECT ON mydb.* TO 'app_read';
GRANT INSERT, UPDATE, DELETE ON mydb.* TO 'app_write';

# grant a role to users
GRANT 'app_read' TO 'alice'@'%', 'bob'@'%';
GRANT 'app_write' TO 'carol'@'%';

# a user must activate a role to use it (default: none)
SET DEFAULT ROLE 'app_read' TO 'alice'@'%';
SET DEFAULT ROLE ALL TO 'alice'@'%';

# see active roles
SELECT CURRENT_ROLE();

Password Management

ALTER USER is the modern way to manage passwords (SET PASSWORD is deprecated). PASSWORD EXPIRE forces a reset on next login — useful for password rotation policies. The validate_password component enforces length/complexity at MEDIUM (mixed case, digit, special char) and STRONG (also no dictionary words). ACCOUNT LOCK/UNLOCK temporarily disables an account without dropping it — handy for suspending access during investigations.

mysql
# change your own password
ALTER USER USER() IDENTIFIED BY 'NewPass!2024';

# change another user's password
ALTER USER 'app'@'%' IDENTIFIED BY 'NewAppPass!2024';

# require a password to be expired (user must reset on next login)
ALTER USER 'app'@'%' PASSWORD EXPIRE;

# enforce password policy (8.0+ validate_password component)
INSTALL COMPONENT 'file://component_validate_password';
SET GLOBAL validate_password.policy = 'MEDIUM';  -- 0/LOW,1/MEDIUM,2/STRONG
SET GLOBAL validate_password.length = 12;

# lock an account temporarily
ALTER USER 'app'@'%' ACCOUNT LOCK;
ALTER USER 'app'@'%' ACCOUNT UNLOCK;

Show Grants & Users

mysql.user is the system table holding all accounts — query it to inventory users and flags like account_locked and password_expired. SHOW GRANTS is the friendlier, decoded view of one user's privileges. CURRENT_USER() is the identity used for privilege checks (which may differ from USER() the connection string) — important when connecting through a role or proxy. Audit these regularly for dormant or over-privileged accounts.

mysql
# list all users (MySQL 8.0+)
SELECT user, host, account_locked, password_expired
FROM mysql.user;

# grants for a specific user
SHOW GRANTS FOR 'app'@'%';

# grants for the current session
SHOW GRANTS;
SHOW GRANTS FOR CURRENT_USER();

# who am I?
SELECT CURRENT_USER(), USER();

# active roles for the current session
SELECT CURRENT_ROLE();

# list global privileges
SELECT user, host, Super_priv, Process_priv, Reload_priv
FROM mysql.user;
16

Backup & Restore

mysqldump

mysqldump is the classic logical backup tool — it emits SQL statements that recreate the database. --single-transaction gives a consistent InnoDB snapshot without locking by using a single REPEATABLE READ transaction. --routines/--triggers/--events include stored programs (otherwise omitted). For large databases prefer Percona XtraBackup or MySQL Enterprise Backup (physical backups) which are much faster than mysqldump.

mysql
# dump a single database to a file
mysqldump -u root -p mydb > mydb.sql

# dump all databases
mysqldump -u root -p --all-databases > alldb.sql

# dump with routines, triggers and events
mysqldump -u root -p --routines --triggers --events mydb > mydb_full.sql

# dump schema only (no data) or data only
mysqldump -u root -p --no-data mydb > schema.sql
mysqldump -u root -p --no-create-info mydb > data.sql

# consistent dump of InnoDB in one transaction
mysqldump -u root -p --single-transaction mydb > mydb.sql

# compress on the fly
mysqldump -u root -p mydb | gzip > mydb.sql.gz

Restore from Dump

To restore, pipe the dump file into the mysql client or use SOURCE inside it. The target database must already exist (CREATE DATABASE then USE it). For faster restores disable foreign key and unique checks and autocommit during the load, then re-enable — the dump itself usually contains these guards already. Restoring a compressed dump with gunzip avoids decompressing to disk. Always test restores on a staging server.

mysql
# restore a database dump (database must exist)
mysql -u root -p mydb < mydb.sql

# restore all databases (creates them)
mysql -u root -p < alldb.sql

# restore a compressed dump
gunzip < mydb.sql.gz | mysql -u root -p mydb

# restore from inside the mysql client
SOURCE /path/to/mydb.sql;

# speed up restores: disable checks and autocommit during load
SET foreign_key_checks = 0;
SET unique_checks = 0;
SET autocommit = 0;
SOURCE /path/to/mydb.sql;
COMMIT;
SET foreign_key_checks = 1;
SET unique_checks = 1;

Export to CSV

SELECT ... INTO OUTFILE writes a server-side file limited by the secure_file_priv directory (or disabled entirely if secure_file_priv is NULL) and requires the FILE privilege — it cannot overwrite an existing file. --batch --raw on the mysql client produces a tab-separated file locally without those restrictions, ideal for cron jobs. FIELDS/LINES clauses control delimiters, quoting and escaping for CSV compatibility.

mysql
# server-side export to a file (needs FILE privilege + secure_file_priv)
SELECT id, username, email, created_at
FROM users
INTO OUTFILE '/var/lib/mysql-files/users.csv'
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
ESCAPED BY '\\'
LINES TERMINATED BY '\n';

# client-side export with the mysql client (no FILE privilege needed)
mysql -u root -p -e "SELECT id, username, email FROM users" \
  --batch --raw mydb > users.tsv

# include column headers with a UNION
SELECT 'id','username','email' UNION
SELECT id, username, email FROM users
INTO OUTFILE '/var/lib/mysql-files/users.csv'
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"';

Import from CSV

LOAD DATA INFILE is the fastest way to load CSV — far quicker than INSERT statements. The server-side form reads from a path under secure_file_priv; LOCAL reads a client file (enabled with local_infile on both client and server). IGNORE 1 LINES skips a header. Use @var capture columns then SET to transform values (e.g. parse a date string). For very large loads disable keys/foreign keys and use a single transaction.

mysql
# server-side import (needs FILE privilege)
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
(id, username, email, @created_at)
SET created_at = STR_TO_DATE(@created_at, '%Y-%m-%d %H:%i:%s');

# client-side import (no FILE privilege, sends file from client)
LOAD DATA LOCAL INFILE 'C:/data/users.csv'
INTO TABLE users
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\r\n'
IGNORE 1 LINES (id, username, email);

# enable LOCAL on the client:  --local-infile=1
# and on the server:  SET GLOBAL local_infile = 1;

Binary Log (mysqlbinlog)

The binary log records all data-changing statements for replication and point-in-time recovery. SHOW MASTER STATUS gives the current file and position (the replication coordinates). mysqlbinlog converts a binlog file back to SQL (or row events) for inspection or replay — combine with start/stop times or positions to recover to a precise moment. PURGE BINARY LOGS reclaims disk; let expire_logs_days/binlog_expire_logs_seconds automate this.

mysql
# enable the binary log (my.cnf)
# [mysqld]
# log_bin = mysql-bin
# binlog_format = ROW
# server_id = 1

# show current binlog files and position
SHOW BINARY LOGS;
SHOW MASTER STATUS;

# view events in a binlog file
mysqlbinlog mysql-bin.000123

# filter by time / position
mysqlbinlog --start-datetime="2024-06-01 00:00:00" \
            --stop-datetime="2024-06-01 12:00:00" \
            mysql-bin.000123 > replay.sql

# replay events to recover
mysql -u root -p < replay.sql

# purge old binlogs
PURGE BINARY LOGS BEFORE '2024-06-01 00:00:00';

Clone Plugin & Replication

The clone plugin (8.0+) physically copies an InnoDB data directory much faster than a logical dump, ideal for provisioning replicas or snapshots. For replication, point a replica at a source with CHANGE REPLICATION SOURCE TO (8.0+; was CHANGE MASTER TO) and START REPLICA. GTID (SOURCE_AUTO_POSITION=1) makes replication positioning robust. SHOW REPLICA STATUS reveals lag and errors. Monitor Seconds_Behind_Source for replica health.

mysql
# install the clone plugin (8.0+) for fast physical copy
INSTALL PLUGIN clone SONAME 'mysql_clone.so';

# clone a local data directory to another path
CLONE LOCAL DATA DIRECTORY = '/var/lib/mysql-clone';

# clone from a remote donor (good for provisioning replicas)
CLONE INSTANCE FROM 'donor'@'10.0.0.2':3306
  IDENTIFIED BY 'donorpass';

# set up replication from a donor
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='10.0.0.2', SOURCE_PORT=3306,
  SOURCE_USER='repl', SOURCE_PASSWORD='replpass',
  SOURCE_AUTO_POSITION=1;
START REPLICA;   -- 8.0+ (was START SLAVE)
SHOW REPLICA STATUS\G
17

Performance Optimization

EXPLAIN

EXPLAIN reveals the query plan: which index is used, how many rows are estimated, and whether a filesort or temporary table is needed. The 'type' column ranks access methods — const/eq_ref are best (single-row lookup), ref/range use an index, index scans the whole index, ALL scans the whole table (usually bad). 'Using index' means a covering index-only scan; 'Using filesort'/'Using temporary' warn of expensive operations to optimize.

mysql
# show the execution plan for a query
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

# extended: extra info + warnings
EXPLAIN EXTENDED SELECT ...;
SHOW WARNINGS;

# format as a tree (8.0+) — more readable
EXPLAIN FORMAT=TREE SELECT ...;

# the key columns to read:
#   type        - access method (const > eq_ref > ref > range > index > ALL)
#   key         - the index chosen
#   rows        - estimated rows scanned
#   Extra       - 'Using index' (good) / 'Using filesort' / 'Using temporary'

EXPLAIN ANALYZE (8.0+)

EXPLAIN ANALYZE (8.0.17+) actually executes the query and reports real row counts and timings per iterator, exposing where time is really spent — far more accurate than the estimates in plain EXPLAIN. Use it on SELECT queries in staging. Pair with SHOW WARNINGS to see the optimizer's rewritten query (after view merging, constant folding). For write queries use EXPLAIN on a read-only equivalent or inspect with performance_schema.

mysql
# actually runs the query and reports real per-step timing
EXPLAIN ANALYZE
SELECT u.username, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;

# output shows actual rows, loops and per-iterator ms:
# -> Table scan on users  (actual rows=1000, loops=1)
#     -> Covering index lookup on o  (actual rows=2.4, loops=1000)

# unlike EXPLAIN, this executes the statement (read-only queries only)

# also useful: SHOW WARNINGS after EXPLAIN to see the rewritten query
EXPLAIN SELECT ...;
SHOW WARNINGS\G

Optimizer Hints

Optimizer hints (/*+ ... */) override the planner for a single statement. They are more targeted than SET GLOBAL and survive statement restarts. Use them to fix a bad plan, but treat the underlying cause (stale stats, missing index, skewed data) too — hints decay as data changes. MAX_EXECUTION_TIME caps read-only queries, protecting the server from runaway SELECTs. SET_VAR adjusts a session variable just for one statement.

mysql
# index hints (8.0+ style, after SELECT)
SELECT /*+ INDEX(u idx_email) */ *
FROM users u
WHERE email = '[email protected]';

SELECT /*+ NO_RANGE_OPTIMIZATION(u) */ *
FROM users u WHERE id BETWEEN 1 AND 1000;

# join order / algorithm hints
SELECT /*+ JOIN_ORDER(a,b,c) */ * FROM a JOIN b ON ... JOIN c ON ...;
SELECT /*+ HASH_JOIN(b) */ * FROM a JOIN b ON a.id=b.aid;
SELECT /*+ NLJ(b) */ * FROM a JOIN b ON a.id=b.aid;

# resource hints
SELECT /*+ MAX_EXECUTION_TIME(1000) */ * FROM big_table;
# aborts the query after 1000 ms (read-only)

# SET_VAR hint changes a variable for one statement
SELECT /*+ SET_VAR(sort_buffer_size=16M) */ ... FROM ...;

Slow Query Log

The slow query log captures queries that exceed long_query_time (in seconds, sub-second allowed). Enable log_queries_not_using_indexes to catch full scans even if fast. mysqldumpslow aggregates similar queries and ranks them by time/count/rows so you can prioritize the worst offenders. Percona's pt-query-digest gives far richer reporting (fingerprints, percentiles) — the standard tool for slow-log analysis. Rotate and clean the log regularly.

mysql
# enable in my.cnf
# [mysqld]
# slow_query_log = 1
# slow_query_log_file = /var/log/mysql/slow.log
# long_query_time = 2          # log queries slower than 2s
# log_queries_not_using_indexes = 1
# log_slow_admin_statements = 1

# enable at runtime (persists until restart)
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1;

# analyze the slow log with mysqldumpslow
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
#   -s t sort by total time, -t 10 top 10
#   -s c count, -s r rows, -s l lock time

# pt-query-digest (Percona Toolkit) gives richer analysis
pt-query-digest /var/log/mysql/slow.log

SHOW PROFILE & Performance Schema

SHOW PROFILE breaks a query into stages (opening tables, sorting, sending data) — handy but deprecated. The modern equivalent is performance_schema, a low-overhead instrumentation framework enabled by default in 8.0. Query events_statements_summary_by_digest to find the queries consuming the most aggregate time across the server, then drill into events_stages_history_long for per-stage detail. This is how to find systemic bottlenecks, not just single slow queries.

mysql
# profiling (deprecated but quick) - per-statement stage timings
SET profiling = 1;
SELECT COUNT(*) FROM huge_table;
SHOW PROFILE;
SHOW PROFILE FOR QUERY 1;

# preferred: performance_schema (8.0+, enabled by default)
# top queries by total latency
SELECT digest_text, count_star, sum_timer_wait/1e9 AS total_s
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC LIMIT 10;

# which stage of which query took longest
SELECT event_name, format_time(timer_wait) AS took
FROM performance_schema.events_stages_history_long
ORDER BY timer_wait DESC LIMIT 10;

Index Optimization Tips

Index design wins most performance battles. Follow the leftmost-prefix rule, avoid leading wildcards and don't wrap indexed columns in functions (or use a generated column + index). Aim for covering indexes on hot queries. Keep WHERE conditions sargable (search-argument-able) so an index can be used: created_at >= x AND created_at < y beats DATE(created_at)=x. ANALYZE TABLE refreshes cardinality stats that the optimizer depends on.

mysql
# 1. index columns used in WHERE / JOIN / ORDER BY
#    with the most selective equality column first
CREATE INDEX idx_user_status ON orders(user_id, status, created_at);

# 2. avoid leading wildcards (defeats the index)
SELECT * FROM users WHERE name LIKE 'A%';   -- OK
SELECT * FROM users WHERE name LIKE '%A';   -- full scan

# 3. don't wrap indexed columns in functions
SELECT * FROM orders WHERE DATE(created_at)='2024-01-01';        -- bad
SELECT * FROM orders WHERE created_at >= '2024-01-01'
  AND created_at < '2024-01-02';                                  -- good

# 4. use a covering index so the query is index-only
EXPLAIN SELECT user_id, status FROM orders WHERE user_id=5;
-- 'Using index' in Extra means no table lookup

# 5. refresh statistics after big loads
ANALYZE TABLE orders;
18

JSON Operations

JSON Column & Insert

The JSON type stores JSON in a binary, validated format. Insert with a JSON string literal or build values with JSON_OBJECT() (key/value pairs) and JSON_ARRAY(). Invalid JSON is rejected on insert. Because JSON is stored as a parsed tree, member access is fast, but the whole document is read together — for frequently queried scalar values, extract them into a generated column and index that for the best of both worlds.

mysql
CREATE TABLE products (
  id    INT PRIMARY KEY,
  name  VARCHAR(100),
  attrs JSON
);

# insert JSON literals
INSERT INTO products VALUES
  (1, 'Widget',
   '{"color":"red","size":42,"tags":["new","sale"],"stock":{"ny":5,"la":3}}'),
  (2, 'Gadget', '{"color":"blue","in_stock":true}');

# build JSON with functions
INSERT INTO products VALUES (3, 'Thing',
  JSON_OBJECT('color','green','tags',JSON_ARRAY('new')));

JSON_EXTRACT / -> / ->>

-> extracts a JSON member (returns JSON with quotes); ->> returns it as unquoted text (the common choice for comparisons). Paths use $. for the root, $.key for members, $.arr[0] for array indices, and $.a.b for nesting. JSON_CONTAINS_PATH checks for key existence. JSON_EXTRACT is the function form of ->. To find documents by a member value efficiently, add a generated column + index on attrs->>'$.color'.

mysql
# extract a member (returns JSON)
SELECT JSON_EXTRACT(attrs, '$.color') FROM products WHERE id=1;   -- "red"
SELECT attrs->'$.color'   FROM products WHERE id=1;               -- "red"

# extract as text (unquoted)
SELECT attrs->>'$.color'  FROM products WHERE id=1;               -- red

# array element access
SELECT attrs->>'$.tags[0]' FROM products WHERE id=1;              -- new

# nested paths
SELECT attrs->>'$.stock.ny' FROM products WHERE id=1;             -- 5

# filter rows by a JSON value
SELECT id, name FROM products
WHERE attrs->>'$.color' = 'red';

# exists check
SELECT id FROM products
WHERE JSON_CONTAINS_PATH(attrs, 'one', '$.stock');

JSON_SET / INSERT / REPLACE / REMOVE

These functions return a modified JSON document — you must assign the result back to update the column. JSON_SET upserts (add or update); INSERT only adds missing; REPLACE only updates existing; REMOVE deletes. JSON_MERGE_PATCH (RFC 7396) merges two documents and removes keys set to null — the standard way to apply a partial patch. JSON_MERGE_PRESERVE keeps existing arrays/objects instead of replacing them.

mysql
# SET updates or adds a member (returns the modified document)
UPDATE products
SET attrs = JSON_SET(attrs, '$.color', 'green', '$.weight', 1.2)
WHERE id = 1;

# INSERT adds only if the path doesn't exist
UPDATE products
SET attrs = JSON_INSERT(attrs, '$.code', 'W-100')
WHERE id = 1;

# REPLACE updates only if the path exists
UPDATE products
SET attrs = JSON_REPLACE(attrs, '$.size', 99)
WHERE id = 1;

# REMOVE deletes a member
UPDATE products
SET attrs = JSON_REMOVE(attrs, '$.tags[0]')
WHERE id = 1;

# MERGE_PATCH merges two JSON documents (8.0+)
SET attrs = JSON_MERGE_PATCH(attrs, '{"size":50,"color":null}')

JSON_TABLE (8.0+)

JSON_TABLE (8.0+) is the bridge from JSON to relational — it shreds a JSON document into rows you can join, filter and aggregate like any table. The columns clause maps JSON paths to typed columns. NESTED PATH unpacks nested arrays in one pass. This makes JSON columns practical for semi-structured data: store flexible JSON, shred to relational with JSON_TABLE when you need SQL analytics, and index a generated column for hot paths.

mysql
# turn a JSON array of objects into relational rows
SELECT jt.*
FROM products p,
JSON_TABLE(p.attrs, '$.tags[*]'
  COLUMNS (
    tag VARCHAR(50) PATH '$'  -- each element as a column
  )
) AS jt
WHERE p.id = 1;

# flatten an array of objects
SELECT p.id, jt.color, jt.size
FROM products p,
JSON_TABLE(p.attrs, '$'
  COLUMNS (
    color VARCHAR(20) PATH '$.color',
    size  INT          PATH '$.size'
  )
) AS jt;

# nested arrays: NESTED PATH
JSON_TABLE(j, '$.stock.*'
  COLUMNS (warehouse VARCHAR(5) PATH '$.wh', qty INT PATH '$.qty'))

JSON Aggregate Functions

JSON_ARRAYAGG collects values into a JSON array per group; JSON_OBJECTAGG builds a key->value object (later keys overwrite earlier duplicates). Combined with JSON_OBJECT you can build an array of objects for an API response directly in SQL — handy for assembling nested payloads without post-processing in the application. NULL inputs are skipped by both. Watch result size: a huge group produces a huge JSON value.

mysql
# JSON_ARRAYAGG: collect values into a JSON array
SELECT user_id,
  JSON_ARRAYAGG(email) AS emails
FROM contacts
GROUP BY user_id;
-- ["[email protected]","[email protected]"]

# JSON_OBJECTAGG: key/value pairs into a JSON object
SELECT user_id,
  JSON_OBJECTAGG(type, value) AS prefs
FROM user_prefs
GROUP BY user_id;
-- {"theme":"dark","lang":"en"}

# build a JSON array of objects with JSON_OBJECT + ARRAYAGG
SELECT JSON_ARRAYAGG(
  JSON_OBJECT('id', id, 'name', username)
) AS users
FROM users WHERE status='active';

JSON Validation & Schema

JSON_VALID checks well-formedness (inserts already enforce this). JSON_PRETTY reformats for readability; JSON_STORAGE_SIZE reports bytes. JSON_SCHEMA_VALID (8.0.17+) lets a CHECK constraint enforce a JSON Schema (required keys, types) — a lightweight way to give JSON columns structure without losing flexibility. JSON_TABLE and JSON_EXTRACT accept ON EMPTY / ON ERROR clauses to control behavior when a path is missing or invalid.

mysql
# validate a string is JSON
SELECT JSON_VALID('{"a":1}');          -- 1
SELECT JSON_VALID('{bad}');            -- 0

# pretty-print and minify
SELECT JSON_PRETTY(attrs) FROM products WHERE id=1;
SELECT JSON_STORAGE_SIZE(attrs) FROM products WHERE id=1;  -- bytes

# JSON_SCHEMA_VALID (8.0.17+) enforces a schema with CHECK
CREATE TABLE events (
  id INT PRIMARY KEY,
  data JSON,
  CHECK (JSON_SCHEMA_VALID(
    '{"type":"object","required":["ts","type"],
      "properties":{"ts":{"type":"string"},
                    "type":{"type":"string"}}}', data))
);

# JSON_TABLE with ERROR ON ERROR for strict validation
SELECT * FROM JSON_TABLE(j, '$' COLUMNS(v INT PATH '$.x')
  DEFAULT NULL ON EMPTY ERROR ON ERROR) AS t;
19

Window Functions

ROW_NUMBER()

ROW_NUMBER() assigns a unique sequential integer to each row within its partition, ordered as specified — the canonical way to rank, deduplicate and pick top-N-per-group. Unlike RANK/DENSE_RANK it never produces ties. The DELETE-via-CTE pattern removes duplicates while keeping one row per key (you cannot delete directly from a CTE, so you select the IDs to delete first). Window functions require MySQL 8.0+.

mysql
# unique sequential number per row within a partition
SELECT username, country,
  ROW_NUMBER() OVER (PARTITION BY country ORDER BY created_at) AS rn
FROM users;

# top 3 oldest users per country
WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (PARTITION BY country ORDER BY birth_date) AS rn
  FROM users
)
SELECT * FROM ranked WHERE rn <= 3;

# deduplicate: keep the latest row per email
WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (PARTITION BY email ORDER BY id DESC) AS rn
  FROM users
)
DELETE FROM users WHERE id IN (
  SELECT id FROM ranked WHERE rn > 1
);

RANK() & DENSE_RANK()

RANK() gives ties the same rank and skips the next ranks (1,1,4,5); DENSE_RANK() gives ties the same rank with no gap (1,1,3,4); ROW_NUMBER() never ties (1,2,3,4). Choose by intent: leaderboard-style ranking usually wants RANK or DENSE_RANK. DENSE_RANK is the classic way to find the Nth-highest per group (rank=2 returns the second distinct salary). All three share the same OVER() syntax.

mysql
# all three ranking functions on the same data
SELECT name, score,
  ROW_NUMBER() OVER (ORDER BY score DESC) AS rn,
  RANK()       OVER (ORDER BY score DESC) AS rnk,
  DENSE_RANK() OVER (ORDER BY score DESC) AS drnk
FROM players;

# with scores 100,100,90,80 the functions return:
#   rn: 1,2,3,4         (always sequential)
#   rnk:1,2,4,5         (ties share a rank, next rank skips)
#   drnk:1,2,3,4        (ties share a rank, no gap)

# find the 2nd-highest salary per department
WITH ranked AS (
  SELECT dept, salary,
    DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS dr
  FROM employees
)
SELECT DISTINCT dept, salary FROM ranked WHERE dr = 2;

LAG() & LEAD()

LAG() and LEAD() peek at another row relative to the current one — LAG looks back, LEAD looks forward — without a self join. They take an optional offset (default 1) and a default value for when the offset falls outside the partition (default NULL). Perfect for period-over-period diffs, moving comparisons and detecting streaks/breaks in sequences. Always specify an ORDER BY so 'previous' is well-defined.

mysql
# compare each row to the previous/next row
SELECT day, sales,
  LAG(sales)  OVER (ORDER BY day) AS prev_day,
  sales - LAG(sales) OVER (ORDER BY day) AS diff,
  LEAD(sales) OVER (ORDER BY day) AS next_day
FROM daily_sales;

# with an explicit offset and default value
SELECT day, sales,
  LAG(sales, 7, 0) OVER (ORDER BY day) AS same_day_last_week
FROM daily_sales;

# year-over-year comparison
SELECT year, region, revenue,
  LAG(revenue) OVER (PARTITION BY region ORDER BY year) AS prev_year,
  revenue - LAG(revenue) OVER (PARTITION BY region ORDER BY year) AS yoy
FROM annual_revenue;

Window Aggregates (SUM / AVG OVER)

Aggregate functions with OVER() compute the aggregate over a moving window of rows instead of collapsing them. Without a frame, SUM OVER (ORDER BY x) yields a running total; with ROWS BETWEEN 6 PRECEDING AND CURRENT ROW it's a trailing 7-row moving average. An empty OVER() computes the grand total over all rows. PARTITION BY resets the window per group. Frames use ROWS (physical) or RANGE (value-based) boundaries.

mysql
# running total over time
SELECT day, sales,
  SUM(sales) OVER (ORDER BY day) AS running_total
FROM daily_sales;

# moving average over a 7-day window
SELECT day, sales,
  AVG(sales) OVER (
    ORDER BY day
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS ma7
FROM daily_sales;

# each row's share of its group total
SELECT user_id, amount,
  amount / SUM(amount) OVER (PARTITION BY user_id) AS share
FROM orders;

# grand total alongside detail rows
SELECT username, balance,
  SUM(balance) OVER () AS total_balance
FROM users;

FIRST_VALUE / LAST_VALUE / NTH_VALUE

FIRST_VALUE/NTH_VALUE return a specific row's value within the frame. LAST_VALUE is a common trap: the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so 'last' means the current row, not the partition's last — extend the frame to UNBOUNDED FOLLOWING to get the true last value. NTH_VALUE returns NULL until the Nth row appears. Explicit frames remove ambiguity and are worth always specifying.

mysql
# first value in each partition (per ordering)
SELECT day, region, sales,
  FIRST_VALUE(sales) OVER (PARTITION BY region ORDER BY day) AS first_sale
FROM daily_sales;

# LAST_VALUE needs an explicit frame to include current row!
SELECT day, region, sales,
  LAST_VALUE(sales) OVER (
    PARTITION BY region ORDER BY day
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS latest_so_far
FROM daily_sales;

# the Nth value within a partition
SELECT day, region, sales,
  NTH_VALUE(sales, 3) OVER (
    PARTITION BY region ORDER BY day
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS third
FROM daily_sales;

NTILE & PERCENT_RANK

NTILE(n) splits the ordered rows into n buckets as evenly as possible (earlier buckets get the extra row when it doesn't divide evenly) — handy for quartiles/deciles in reporting. PERCENT_RANK() is the relative rank as a 0..1 fraction; CUME_DIST() is the cumulative distribution (fraction of rows at or below the current value). Use NTILE for bucketizing and PERCENT_RANK/CUME_DIST for percentile-style analytics.

mysql
# divide rows into N equal buckets (1..N)
SELECT name, salary,
  NTILE(4) OVER (ORDER BY salary DESC) AS quartile
FROM employees;
-- quartile 1 = top 25%, quartile 4 = bottom 25%

# relative rank as a fraction 0..1
SELECT name, salary,
  PERCENT_RANK() OVER (ORDER BY salary) AS pct,
  CUME_DIST()    OVER (ORDER BY salary) AS cum
FROM employees;
-- pct  = (rank-1)/(N-1), 0 = lowest, 1 = highest
-- cum  = (rows up to and including current)/N

# CUME_DIST: fraction of rows with a value <= current
20

Date & Time Handling

Current Date/Time Functions

NOW()/CURRENT_TIMESTAMP return the same value throughout a statement (consistent); SYSDATE() reflects the real time at each call (avoid it — it is non-deterministic and breaks replication). Store timestamps in UTC (UTC_TIMESTAMP) and convert to local time on display. NOW(6) gives microsecond precision. UNIX_TIMESTAMP converts to/from epoch seconds — convenient for interop with languages that use epoch time.

mysql
# current timestamp (date + time) in one call
SELECT NOW(), SYSDATE(), CURRENT_TIMESTAMP, LOCALTIME;

# current date / time separately
SELECT CURDATE(), CURRENT_DATE;     -- '2024-07-02'
SELECT CURTIME(), CURRENT_TIME;     -- '14:30:00'

# UTC equivalents (store UTC, display local)
SELECT UTC_TIMESTAMP(), UTC_DATE(), UTC_TIME();

# microsecond precision with (6)
SELECT NOW(6), CURRENT_TIMESTAMP(6);

# UNIX timestamp (seconds since epoch) and back
SELECT UNIX_TIMESTAMP();                 -- 1719912600
SELECT FROM_UNIXTIME(1719912600);        -- '2024-07-02 12:30:00'
SELECT FROM_UNIXTIME(1719912600, '%Y-%m-%d');

DATE_FORMAT & STR_TO_DATE

DATE_FORMAT formats a date/time to a string using specifiers — note lowercase %i (minutes) and %s (seconds), a common gotcha. STR_TO_DATE is the inverse, parsing a string into a DATE/TIME/DATETIME. Always use STR_TO_DATE for parsing user input rather than relying on implicit string-to-date conversion, which depends on format settings and is fragile. Store dates as DATE/DATETIME types, not strings.

mysql
# format a date/time to a string
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s');   -- 2024-07-02 14:30:00
SELECT DATE_FORMAT(NOW(), '%d/%m/%Y');            -- 02/07/2024
SELECT DATE_FORMAT(NOW(), '%W, %M %e %Y');        -- Tuesday, July 2 2024

# parse a string into a date
SELECT STR_TO_DATE('02/07/2024', '%d/%m/%Y');     -- 2024-07-02
SELECT STR_TO_DATE('Jul 2, 2024 2:30 PM', '%b %e, %Y %h:%i %p');

# common specifiers:
#   %Y 4-digit year   %y 2-digit year
#   %m month(01-12)   %c month(1-12)   %M month name   %b abbreviated
#   %d day(01-31)     %e day(1-31)     %j day of year
#   %H hour(00-23)    %h hour(01-12)   %i minutes  %s seconds
#   %W weekday name   %a abbreviated   %p AM/PM

DATE_ADD / DATE_SUB & INTERVAL

DATE_ADD/DATE_SUB with INTERVAL is the canonical date arithmetic; the + INTERVAL / - INTERVAL shorthand is cleaner. INTERVAL handles month/year rollovers correctly (Jan 31 + 1 month = Feb 29 in a leap year). LAST_DAY returns the last day of the argument's month — handy for month-end logic. Compound units like YEAR_MONTH take '1-2' (1 year 2 months). Never use string math for dates — it breaks on edge cases.

mysql
# add an interval to a date
SELECT DATE_ADD('2024-01-31', INTERVAL 1 MONTH);   -- 2024-02-29
SELECT DATE_ADD('2024-01-31', INTERVAL 1 DAY);     -- 2024-02-01
SELECT '2024-01-31' + INTERVAL 1 MONTH;            -- shorthand

# subtract
SELECT DATE_SUB(NOW(), INTERVAL 7 DAY);
SELECT NOW() - INTERVAL 1 HOUR;

# interval units: MICROSECOND SECOND MINUTE HOUR DAY WEEK MONTH QUARTER YEAR
#   SECOND_MICROSECOND MINUTE_MICROSECOND ... YEAR_MONTH (compound)

# add to a datetime (keeps the time component)
SELECT DATE_ADD(NOW(), INTERVAL 30 MINUTE);

# last day of next month
SELECT LAST_DAY(DATE_ADD(CURDATE(), INTERVAL 1 MONTH));

DATEDIFF / TIMESTAMPDIFF

DATEDIFF returns whole days (date1 - date2, ignoring time). TIMESTAMPDIFF returns the difference in a unit you choose (unit, from, to) and truncates toward zero — so TIMESTAMPDIFF(YEAR, ...) gives age only if the birthday has passed this year; for an exact age subtract 1 when the month/day hasn't occurred yet. Watch the argument order: DATEDIFF is (a,b) = a-b; TIMESTAMPDIFF is (unit, from, to) = to-from.

mysql
# DATEDIFF: difference in DAYS (date1 - date2)
SELECT DATEDIFF('2024-12-31', '2024-01-01');   -- 365
SELECT DATEDIFF(NOW(), birth_date) AS days_alive FROM users;

# TIMESTAMPDIFF: difference in a chosen unit (date1, date2) -> date2 - date1
SELECT TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) AS age;
SELECT TIMESTAMPDIFF(MONTH, '2024-01-15', '2024-07-02');
SELECT TIMESTAMPDIFF(MICROSECOND, '2024-07-02 09:00:00', NOW());

# units: MICROSECOND SECOND MINUTE HOUR DAY WEEK MONTH QUARTER YEAR
# (TIMESTAMPDIFF argument order is (unit, from, to))

# TIME values: TIMEDIFF returns a TIME
SELECT TIMEDIFF('18:00:00', '09:30:00');   -- 08:30:00

EXTRACT & Date Parts

EXTRACT(unit FROM date) returns a single numeric component; YEAR()/MONTH()/DAY() etc. are shorthand for the common ones; DAYNAME/MONTHNAME return names. Grouping by YEAR()/MONTH() of a timestamp is the standard way to build monthly reports. Note DAYOFWEEK is 1=Sunday..7=Saturday while WEEKDAY is 0=Monday..6=Sunday — pick the one matching your convention. WEEK() has modes for week-start day and counting.

mysql
# pull out a single component
SELECT EXTRACT(YEAR FROM created_at)   AS yr,
       EXTRACT(MONTH FROM created_at)  AS mon,
       EXTRACT(DAY FROM created_at)    AS dy
FROM orders;

# dedicated functions for each part
SELECT YEAR(created_at), MONTH(created_at), DAY(created_at),
       HOUR(created_at), MINUTE(created_at), SECOND(created_at),
       DAYOFWEEK(created_at), DAYOFYEAR(created_at),
       WEEK(created_at), QUARTER(created_at);

# names instead of numbers
SELECT DAYNAME(created_at), MONTHNAME(created_at);

# group orders by month for a report
SELECT YEAR(created_at) AS yr, MONTH(created_at) AS mon,
       COUNT(*) AS orders, SUM(amount) AS revenue
FROM orders
GROUP BY yr, mon
ORDER BY yr, mon;

Time Zone Conversion

Set the session time_zone so TIMESTAMP columns display in local time; named zones (Asia/Shanghai) need the system timezone tables loaded via mysql_tzinfo_to_sql. CONVERT_TZ converts a literal datetime between zones. Best practice: store all times in UTC (use TIMESTAMP or DATETIME in UTC), set each session's time_zone for display, and convert with CONVERT_TZ when needed. This avoids ambiguity across users in different regions.

mysql
# see the session time zone
SELECT @@session.time_zone, @@global.time_zone;

# set the session time zone (UTC offset or named, needs tz data loaded)
SET time_zone = '+08:00';            # Asia/Shanghai offset
SET time_zone = 'Asia/Shanghai';     # named zone (load tz tables first)

# convert a datetime between time zones
SELECT CONVERT_TZ('2024-07-02 09:00:00', '+00:00', '+08:00');
-- 2024-07-02 17:00:00

# named zones require the timezone tables to be loaded:
#   mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql

# TIMESTAMP columns auto-convert to the session time zone on display;
# DATETIME columns do not -- they store the literal value

# store all times in UTC and convert on the way out
SELECT CONVERT_TZ(created_at, '+00:00', @@session.time_zone)
FROM orders;

Was this helpful?