Chapter 09 · Index Design, FULLTEXT, Spatial, Vector, and Specialized Access Paths
Functional/Expression and Generated-Column Indexing Patterns
Index deterministic expressions in MariaDB with generated columns, exact type/collation semantics, and 11.8+ virtual-column optimizer substitution—without importing MySQL functional-index syntax.
Learning outcomes
ServiceHub stores user email exactly as entered, but login
lookups must be case-normalized. Another table stores a small
JSON document whose severity field is frequently
filtered. A developer coming from MySQL 8 tries
CREATE INDEX idx_lower_email ON users ((LOWER(email)))
and assumes MariaDB accepts the same functional-index DDL. On
the current MariaDB line, that is the wrong portability
assumption.
MariaDB’s practical pattern is to define a generated column
whose value is a deterministic expression and index that column.
From MariaDB 11.8, the optimizer can also recognize the exact
indexed virtual-column expression in a
WHERE condition and substitute the virtual column;
from MariaDB 12.1, related recognition was improved for
ORDER BY/GROUP BY. MariaDB still
requires the generated-column object rather than MySQL-style
direct expression-index syntax.
Distinguish direct expression-index syntax from MariaDB generated-column indexing.
Choose VIRTUAL versus PERSISTENT/STORED generated columns from evaluation, storage and maintenance needs.
Build deterministic indexed expressions with explicit data types and collations.
Use MariaDB 11.8+ virtual-column substitution without assuming semantically equivalent expressions match.
Diagnose expression/type/collation mismatch and measure the write/read tradeoff of maintaining derived keys.
Mandatory examples target MariaDB 12.3.2. The optimizer feature that recognizes indexed virtual-column expressions in WHERE is available from 11.8. MariaDB documentation explicitly notes that direct CREATE INDEX ON expression syntax is not the MariaDB mechanism; create a virtual/generated column and index it.
1. Prove the MySQL-style assumption wrong first
USE servicehub_index_lab;CREATE TABLE service_users ( user_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, email VARCHAR(254) NOT NULL, profile JSON NOT NULL) ENGINE=InnoDB;-- Intentionally use syntax seen in another product/version family:CREATE INDEX idx_lower_email_directON service_users ((LOWER(email)));
Do not memorize a specific parser error number here; exact diagnostics can evolve. The learning point is the contract: current MariaDB documentation says to create a generated/virtual column and index that column. Compatibility claims must be tested against the exact product and version rather than inferred from shared SQL heritage.
2. Generated columns turn an expression into an indexable schema object
ALTER TABLE service_users ADD COLUMN email_norm VARCHAR(254) COLLATE utf8mb4_uca1400_ai_ci AS (LOWER(email)) VIRTUAL, ADD COLUMN severity INT AS (CAST(JSON_VALUE(profile,'$.severity') AS INTEGER)) VIRTUAL, ADD INDEX idx_email_norm (email_norm), ADD INDEX idx_severity (severity);INSERT INTO service_users(email,profile) VALUES ('Alice.Example@Example.COM','{"severity":2,"team":"network"}'), ('bob@example.com','{"severity":5,"team":"field"}'), ('carol@example.com','{"severity":2,"team":"network"}');SHOW CREATE TABLE service_users\GSHOW INDEX FROM service_users;
A VIRTUAL generated column computes its value when needed rather than storing the derived value as a normal field. A PERSISTENT (also accepted as STORED) generated column stores the generated result. MariaDB can index both virtual and persistent generated columns when the storage engine supports them. The index itself still consumes storage and must be maintained on writes even if the generated column is virtual.
3. On 11.8+, the optimizer can substitute the exact virtual expression in WHERE
EXPLAINSELECT user_id,emailFROM service_usersWHERE severity=2;EXPLAINSELECT user_id,emailFROM service_usersWHERE CAST(JSON_VALUE(profile,'$.severity') AS INTEGER)=2;ANALYZE FORMAT=JSONSELECT user_id,emailFROM service_usersWHERE CAST(JSON_VALUE(profile,'$.severity') AS INTEGER)=2;
On MariaDB 11.8 and later, the optimizer can recognize the exact expression used by an indexed virtual column and rewrite the condition to use that virtual column for range/ref access. This is a targeted optimizer transformation, not a general theorem prover. The expression must match the generated-column definition exactly enough for substitution.
Use EXPLAIN to confirm the selected key and ANALYZE FORMAT=JSON to compare estimated rows with observed rows. If optimizer trace is enabled in a disposable diagnostic session, virtual-column substitution can also appear in the trace. Do not enable heavyweight tracing globally on production merely to prove a classroom point.
4. Deliberately wrong: change the expression or collation and expect the same index
-- The indexed definition casts to INTEGER.EXPLAIN SELECT user_idFROM service_usersWHERE JSON_VALUE(profile,'$.severity') = '2';-- The indexed definition uses LOWER(email) with a declared collation.EXPLAIN SELECT user_idFROM service_usersWHERE UCASE(email) = UCASE('alice.example@example.com');
These predicates can be semantically related to the generated
values but are not the same indexed expression. Type conversion
and collation are part of the expression contract. The safe
repair is either to query the generated column directly—WHERE severity=2, WHERE email_norm=LOWER(?)—or to use the exact
supported expression and verify the plan on the target MariaDB
version.
For string extraction from JSON, MariaDB documentation
specifically warns that JSON_VALUE inherits a
binary-style collation context from JSON text. If your
application expects a Unicode case-insensitive collation,
declare that collation on the generated column and keep query
comparison semantics aligned with it.
5. Determinism is a correctness requirement, not only an optimizer preference
Indexed or persistent generated values must remain consistent when the row is written and later searched. Expressions depending on nondeterministic state, external data, session modes or mutable configuration can make that promise unsafe. MariaDB permits many generated expressions but restricts nondeterministic functions for indexed virtual or persistent generated columns.
SELECT COLUMN_NAME,DATA_TYPE,COLLATION_NAME,IS_GENERATED,GENERATION_EXPRESSION,EXTRAFROM information_schema.COLUMNSWHERE TABLE_SCHEMA='servicehub_index_lab' AND TABLE_NAME='service_users'ORDER BY ORDINAL_POSITION;
Treat GENERATION_EXPRESSION, data type and
collation as deployable schema. A migration that silently
changes the expression, SQL mode or collation can change lookup
semantics even when application SQL is unchanged. Keep
generated-index definitions under schema migration control and
validate them during upgrades.
6. VIRTUAL versus PERSISTENT is a workload decision
| Choice | Benefit | Cost / risk |
|---|---|---|
| VIRTUAL + index | Avoids storing an additional table column value while providing an index key | Expression is evaluated for index maintenance and reads that need the column; index still consumes space. |
| PERSISTENT + index | Stores the derived value and can reduce repeated computation for selected reads | Adds row storage plus index storage and write maintenance. |
| Application-computed real column | Maximum control and cross-database portability | Application must keep the derived value correct; duplication can drift without constraints/process discipline. |
| No derived index | Simplest writes/schema | Expression predicates may require scans or other access paths. |
Do not select PERSISTENT because it sounds “faster,” or VIRTUAL because it sounds “free.” Build a representative dataset, measure write rate and query rate, and compare execution evidence. The same expression can have very different economics on a read-heavy catalog versus a write-heavy event stream.
7. Reproducible lab, verification, and production judgment
-
Create
service_usersand observe the intentionally unsupported direct-expression-index attempt. - Add virtual generated columns and indexes with explicit type/collation.
- Use EXPLAIN on direct generated-column predicates.
- On MariaDB 11.8+, test the exact expression and verify whether the optimizer chooses the generated-column index.
- Change the expression or collation and observe the plan difference.
- Record schema metadata so the generated expression is part of migration review.
Check your understanding
- What is the MariaDB pattern for indexing LOWER(email) on the current baseline?
- Why can a VIRTUAL generated column still increase write cost when indexed?
- What changed in MariaDB 11.8 for indexed virtual-column expressions?
- Why can a different cast or collation prevent expected index use?
- When might PERSISTENT be reasonable instead of VIRTUAL?
Review the answers
Define a generated column such as email_norm AS (LOWER(email)) and index that column; MariaDB does not use MySQL-style direct expression-index DDL for this case. An index over a virtual column still stores and maintains index entries on writes. From 11.8, the optimizer can recognize the exact indexed virtual-column expression in WHERE and substitute the column for range/ref access. Type/collation differences change the expression and comparison contract. PERSISTENT can be reasonable when storing the derived result is worth its row-space/write cost for the measured workload.
The next lesson applies indexing to tokenized text search. FULLTEXT is not a B-tree over strings; it has its own token, stopword, relevance and rebuild semantics.
8. Treat expression indexes as versioned schema contracts
A generated-column index is more than a convenience for one
query. It becomes part of the physical schema contract that
application code, migration scripts, collations, SQL mode and
optimizer behavior depend on. Before deploying one, capture
SHOW CREATE TABLE, the exact generation
expression, data type, collation and representative
EXPLAIN output. Repeat those checks after a
MariaDB upgrade because optimizer capabilities can improve
between releases even when the table definition is unchanged.
This matters especially for JSON-derived keys. MariaDB stores
JSON using its own semantics, and
JSON_VALUE() returns a scalar whose SQL
type/collation must be made explicit when the indexed business
key is numeric or case-insensitive text. A generated
expression that casts a value to INTEGER is not
interchangeable with a string comparison simply because
today’s sample data contains only digits. Likewise, a
case-folded email key must use a collation that matches the
login identity rule, not whatever server default happens to be
active during deployment.
For zero-downtime schema change, treat creation of the generated column and its index like any other potentially expensive DDL. Estimate table size, test DDL algorithm/locking behavior on the exact release, monitor replicas or Galera if present, and have a rollback plan. On a busy table, the operational cost of building an index can dominate the eventual per-query benefit. The correct acceptance criterion therefore has two parts: the new access path must improve the target workload, and the deployment must fit the system’s change window and recovery model.
Prefer the simplest derived key that expresses a stable business predicate. If application code can query a real normalized column directly with clearer semantics, that may be better than hiding complex logic in a generated expression. Use generated-column indexing when it improves correctness and access-path clarity—not merely to avoid changing SQL.