Generated columns and functional indexes
Columns MySQL computes for you, VIRTUAL or STORED, and how they (or a functional index) make a WHERE on an expression or a JSON path searchable.
After this lesson you can
- Declare VIRTUAL and STORED generated columns and say how they differ
- Index an expression with a generated column or a functional index
- Explain why a query should name the generated column rather than repeat its expression
- List what a generated column may not contain
A generated column is defined by an expression over other columns of the same row. You never write it; MySQL computes it.
price_gross DECIMAL(10,2) AS (ROUND(price * (100 + vat_pct) / 100, 2)) STORED,
name_upper VARCHAR(30) AS (UPPER(name)) VIRTUAL
VIRTUAL(the default) is computed when the row is read and takes no space in the row.STOREDis computed when the row is inserted or updated and stored like a normal column.
Both can be indexed. For a VIRTUAL column, the index itself stores the
computed values, so a lookup through it does not recompute anything.
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(30) NOT NULL, price DECIMAL(10,2) NOT NULL, vat_pct INT NOT NULL, price_gross DECIMAL(10,2) AS (ROUND(price * (100 + vat_pct) / 100, 2)) STORED, name_upper VARCHAR(30) AS (UPPER(name)) VIRTUAL);INSERT INTO products (id, name, price, vat_pct) VALUES (1, 'pen', 10.00, 18), (2, 'book', 25.00, 0);UPDATE products SET price = 12.50 WHERE id = 1; SELECT p.id, p.price, p.price_gross, p.name_upper, gross.EXTRA AS price_gross_kind, upper_.EXTRA AS name_upper_kindFROM products pJOIN information_schema.COLUMNS gross ON gross.TABLE_SCHEMA = DATABASE() AND gross.TABLE_NAME = 'products' AND gross.COLUMN_NAME = 'price_gross'JOIN information_schema.COLUMNS upper_ ON upper_.TABLE_SCHEMA = DATABASE() AND upper_.TABLE_NAME = 'products' AND upper_.COLUMN_NAME = 'name_upper'ORDER BY p.id;Changing price recomputed price_gross; nobody had to remember to.
information_schema.COLUMNS.EXTRA reports which kind each column is.
Making an expression searchable
The data-types lesson showed, and the dates lesson warned, that converting or wrapping a column in a function stops an index on that column from being searched. A generated column turns the expression into a column you can index. MySQL 8.0.13 added a shortcut, the functional index, which is an index on an expression with a hidden generated column behind it:
KEY idx_lower_email ((LOWER(email))) -- note the double parentheses
CREATE TABLE users ( id INT PRIMARY KEY, email VARCHAR(100) NOT NULL, profile JSON NOT NULL, email_domain VARCHAR(100) AS (SUBSTRING_INDEX(email, '@', -1)) VIRTUAL, country CHAR(2) AS (profile->>'$.country') STORED, KEY idx_domain (email_domain), KEY idx_country (country), KEY idx_lower_email ((LOWER(email))));INSERT INTO users (id, email, profile) VALUES (1, 'Ann@Example.com', '{"country": "GE"}'), (2, 'bob@test.io', '{"country": "DE"}'), (3, 'cy@example.com', '{"country": "GE"}'), (4, 'dee@foo.org', '{"country": "PL"}'), (5, 'eve@bar.net', '{"country": "FR"}'), (6, 'fay@baz.com', '{"country": "IT"}'), (7, 'gus@qux.com', '{"country": "ES"}'), (8, 'hal@zed.com', '{"country": "US"}');ANALYZE TABLE users;-- Helper: show_plan(query) stores EXPLAIN FORMAT=JSON for each query.CREATE TABLE plans (n INT AUTO_INCREMENT PRIMARY KEY, query TEXT, plan JSON);CREATE PROCEDURE show_plan(IN q TEXT)BEGIN SET @sql = CONCAT('EXPLAIN FORMAT=JSON INTO @p ', q); PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; INSERT INTO plans (query, plan) VALUES (q, @p);END;CALL show_plan('SELECT id FROM users WHERE email_domain = ''example.com''');CALL show_plan('SELECT id FROM users WHERE SUBSTRING_INDEX(email, ''@'', -1) = ''example.com''');CALL show_plan('SELECT id FROM users WHERE profile->>''$.country'' = ''GE''');CALL show_plan('SELECT id FROM users WHERE LOWER(email) = ''ann@example.com''');CALL show_plan('SELECT id FROM users WHERE email = ''ann@example.com'''); SELECT n, query, JSON_UNQUOTE(JSON_EXTRACT(JSON_EXTRACT(plan, '$**.access_type'), '$[0]')) AS access_type, JSON_UNQUOTE(JSON_EXTRACT(JSON_EXTRACT(plan, '$**.key'), '$[0]')) AS index_usedFROM plansORDER BY n;- Filtering on the generated column by name (
email_domain = ...) uses its index. - Writing the same
SUBSTRING_INDEX(...)expression instead was not matched to that column here, and the table was scanned. - The JSON path
profile->>'$.country'was matched to the storedcountrycolumn and its index. - The functional index served
LOWER(email) = ..., and the plainemail = ...could not use it.
MySQL can substitute a generated column for an identical expression, but whether it does depends on the expression and its exact result type. The dependable habit is to query the generated column by name.
What a generated column may not do
CREATE TABLE users ( id INT PRIMARY KEY, email VARCHAR(100) NOT NULL, profile JSON NOT NULL, country CHAR(2) AS (profile->>'$.country') STORED);-- A helper that runs a statement and records the error instead of stopping.CREATE TABLE attempts (n INT AUTO_INCREMENT PRIMARY KEY, statement TEXT, outcome TEXT);CREATE PROCEDURE try_sql(IN stmt TEXT)BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN GET DIAGNOSTICS CONDITION 1 @err_no = MYSQL_ERRNO, @err_msg = MESSAGE_TEXT; INSERT INTO attempts (statement, outcome) VALUES (stmt, CONCAT('error ', @err_no, ': ', REPLACE(@err_msg, CONCAT(DATABASE(), '.'), ''))); END; SET @sql = stmt; PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; INSERT INTO attempts (statement, outcome) VALUES (stmt, 'ok');END;CALL try_sql('INSERT INTO users (id, email, profile, country) VALUES (1, ''a@x.io'', ''{}'', ''XX'')');CALL try_sql('ALTER TABLE users ADD COLUMN created_day DATE AS (CURDATE())');CALL try_sql('INSERT INTO users (id, email, profile) VALUES (2, ''b@x.io'', ''{"country": "Georgia"}'')');CALL try_sql('INSERT INTO users (id, email, profile) VALUES (3, ''c@x.io'', ''{"country": "GE"}'')');CALL try_sql('CREATE INDEX idx_lower ON users (LOWER(email))');CALL try_sql('CREATE INDEX idx_lower ON users ((LOWER(email)))'); SELECT n, outcome FROM attempts ORDER BY n;- You cannot supply a value for it (error 3105); leave it out or use
DEFAULT. - The expression must be deterministic and self-contained:
NOW(),CURDATE()andRAND()are refused (error 3763 forCURDATE()above), and so are user variables and subqueries, with their own error numbers. - The computed value must fit the declared type. In strict mode a JSON
value too long for
CHAR(2)makes the insert fail (error 1406), which is one way to enforce the shape of a JSON field.
Common mistakes
- Indexing a generated column and then filtering on the expression. It
may or may not be matched; the
SUBSTRING_INDEXquery above scanned the table. - Forgetting the double parentheses of a functional index.
(LOWER(email))is a syntax error (attempt 5);((LOWER(email)))works. - Trying to
UPDATEa generated column. Update the columns it is computed from. - Using
STOREDby default.VIRTUALcosts no row space; chooseSTOREDwhen the expression is expensive or the column must be part of the primary key.
Exercise · Pro
Reprice books, let the gross price follow
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade