SQL (PostgreSQL, MySQL, SQLite) Module 9 – Creating tables and constraints
Generated columns
Define stored and virtual generated columns in PostgreSQL 18, MySQL and SQLite, index them, and learn each engine's defaults, limits and expression rules.
What you will learn
- Define stored and virtual generated columns in each engine
- Choose between STORED and VIRTUAL from how the column is read and written
- Index a generated column and check that queries use the index
- Write generation expressions that every engine accepts, including exact money arithmetic
Before you start
What you’ll learn · Before you start
What you will learn
- Define stored and virtual generated columns in each engine
- Choose between STORED and VIRTUAL from how the column is read and written
- Index a generated column and check that queries use the index
- Write generation expressions that every engine accepts, including exact money arithmetic
Before you start
On this page
A generated column holds a value that the database computes from the other columns of the same row: a line total from price and quantity, a lower-case copy of an email address for searching, or a field taken out of a JSON document. Nobody writes it. The database computes it again whenever the row’s other columns change, so it can never disagree with them, whichever program wrote the row. There are two kinds. A STORED generated column is computed on every write and kept in the row like any other column. A VIRTUAL generated column is computed on every read and takes no space in the row. SQLite and MySQL have both and use VIRTUAL when you name neither; PostgreSQL had only STORED until version 18, which added VIRTUAL and made it the default. The runnable examples use sql.js 1.14.2 and so SQLite 3.49.1; where PostgreSQL 18 or MySQL 8.4 behave otherwise, the text says so.
Two generated columns at work
-- Order lines whose total and tax the database computes: one generated column computed on every read (VIRTUAL),
-- one computed on every write and kept in the row (STORED).
CREATE TABLE order_lines (
line_id INTEGER PRIMARY KEY,
product TEXT NOT NULL,
unit_price_paise INTEGER NOT NULL,
qty INTEGER NOT NULL,
line_total_paise INTEGER GENERATED ALWAYS AS (unit_price_paise * qty) VIRTUAL,
tax_paise INTEGER GENERATED ALWAYS AS ((unit_price_paise * qty * 18 + 50) / 100) STORED
);
INSERT INTO order_lines (product, unit_price_paise, qty) VALUES
('Basmati rice 1 kg', 14999, 2), ('Toor dal 500 g', 8950, 1), ('Masala chai 250 g', 12000, 3);
SELECT product, unit_price_paise, qty, line_total_paise, tax_paise FROM order_lines;
-- Change the quantity, and both generated columns follow.
UPDATE order_lines SET qty = 4 WHERE product = 'Toor dal 500 g';
SELECT product, qty, line_total_paise, tax_paise FROM order_lines WHERE product = 'Toor dal 500 g';
-- A generated column cannot be written, not even with the value it would compute.
INSERT INTO order_lines (product, unit_price_paise, qty, line_total_paise) VALUES ('Ghee 500 ml', 32000, 1, 32000);
-- The catalog marks them: hidden = 2 for a virtual column, 3 for a stored one.
SELECT name, hidden FROM pragma_table_xinfo('order_lines') WHERE hidden > 0; Output (exit status 1)
┌───────────────────┬──────────────────┬─────┬──────────────────┬───────────┐ │ product │ unit_price_paise │ qty │ line_total_paise │ tax_paise │ ├───────────────────┼──────────────────┼─────┼──────────────────┼───────────┤ │ Basmati rice 1 kg │ 14999 │ 2 │ 29998 │ 5400 │ │ Toor dal 500 g │ 8950 │ 1 │ 8950 │ 1611 │ │ Masala chai 250 g │ 12000 │ 3 │ 36000 │ 6480 │ └───────────────────┴──────────────────┴─────┴──────────────────┴───────────┘ ┌────────────────┬─────┬──────────────────┬───────────┐ │ product │ qty │ line_total_paise │ tax_paise │ ├────────────────┼─────┼──────────────────┼───────────┤ │ Toor dal 500 g │ 4 │ 35800 │ 6444 │ └────────────────┴─────┴──────────────────┴───────────┘ ┌──────────────────┬────────┐ │ name │ hidden │ ├──────────────────┼────────┤ │ line_total_paise │ 2 │ │ tax_paise │ 3 │ └──────────────────┴────────┘
Printed as an error (standard error)
Parse error near line 20: cannot INSERT into generated column "line_total_paise"
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < line_totals.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The inserts named only the product, its price and its quantity, and both generated columns appeared with their
values. When the quantity of dal changed to 4, the total and the tax followed without any code asking for it. The
insert that tried to write line_total_paise itself was refused, even though it wrote the value the column would
have had: a generated column belongs to the database. The tax is computed with whole numbers only, adding 50 before
dividing by 100 so that it rounds half up, which keeps it exact in every engine. In the catalog,
pragma_table_xinfo() marks a virtual column with hidden 2 and a stored one with 3.
When each kind of generated column is computed
Text description of the diagram
The diagram follows one order line from top to bottom.
- An INSERT or UPDATE writes the ordinary columns unit_price_paise and qty.
- The STORED generated column tax_paise is computed at that moment and written into the row, on every write that changes the row.
- The row on disk holds unit_price_paise, qty and tax_paise. It does not hold the virtual column.
- Later, a SELECT reads the row.
- The VIRTUAL generated column line_total_paise is computed at that moment, from the row just read, on every read.
- The result contains every column, stored and virtual alike.
STORED or VIRTUAL
| Criterion | STORED | VIRTUAL |
|---|---|---|
| Computed | On every insert, and on every update of the row | On every read of the column |
| Space in the row | Yes, like any other column | None |
| Can be indexed | Yes, in all three engines | In SQLite and MySQL; not in PostgreSQL 18 |
| Adding one to a table that has rows | SQLite refuses it; PostgreSQL rewrites the table; MySQL copies it | Quick in all three: nothing is written |
| The default when neither is named | No engine | SQLite, MySQL and PostgreSQL 18 |
| When to choose | The column is read far more often than written, or an engine needs it stored to index it | The column is cheap to compute, or rarely read, or added to a large table |
A stored column costs space and a little work on every write, and saves the work on every read; a virtual one is the other way round. For a cheap expression, such as a product of two numbers, VIRTUAL is the natural choice. For an expensive one that is read often, or one that must carry an index or a unique rule in PostgreSQL, STORED pays its way.
Where generated columns help
Four uses cover most of them. Derived amounts that must never drift from their inputs, such as a line total or a tax amount. Search keys that normalise a value, such as an email address in lower case or a product code without spaces, so that every query and every rule uses the same form. Fields pulled out of a JSON document, so that they can be filtered, sorted and constrained like ordinary columns. Parts of a value that queries group by, such as the year of an invoice date. What a generated column cannot hold is anything computed from other rows or tables, such as the total of an order from its items: that needs a view, a summary table kept by a trigger, or a query, which later modules cover.
Writing the generation expression
The syntax is the same everywhere: name type GENERATED ALWAYS AS (expression) STORED or VIRTUAL, and SQLite and
MySQL also accept the short form name type AS (expression). The expression has rules of its own:
- The same row only. It may use the row’s ordinary columns, literals and functions, but no subquery and no other row.
- The same answer every time. Functions whose result can change, such as
random()or the current time, are refused, because a stored value would go stale and a virtual one would change between two reads. - Other generated columns may be used in SQLite, and in MySQL if they are defined earlier; PostgreSQL forbids it.
- Constraints still apply. A generated column may carry
NOT NULLandCHECK, which test the computed value.
Your turn
Exercise · Easy · SQL
A price with tax that the database keeps up to date
Each test fills the table catalogue (product_id, name, price_paise, tax_pct). Complete the CREATE TABLE products statement with a generated column price_with_tax_paise: the price plus its tax, in whole paise, rounded half up. For example, 1,000 paise at 5 % is 1,050 paise, and 999 paise at 5 % is 1,048.95 paise, so 1,049.
Use only whole numbers in the expression, so that nothing depends on floating point. Leave the lines below the CREATE TABLE as they are: they copy the catalogue in, raise one price to check that the column follows, and return the products whose price with tax is at most 50,000 paise.
Starter code · products.sql
-- Add the generated column price_with_tax_paise: price plus tax, in whole paise, rounded half up.
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price_paise INTEGER NOT NULL,
tax_pct INTEGER NOT NULL
);
-- Leave these lines as they are.
INSERT INTO products (product_id, name, price_paise, tax_pct)
SELECT product_id, name, price_paise, tax_pct FROM catalogue;
UPDATE products SET price_paise = price_paise + 100 WHERE product_id = 1;
SELECT product_id, name, price_with_tax_paise FROM products WHERE price_with_tax_paise <= 50000 ORDER BY product_id; The sample tests · tests.yaml
# Sample tests: each one fills the table catalogue, runs your script and compares the rows it returns, in order.
tests:
- name: adds the tax, rounds half up, and follows a changed price
ordered: true
setup: |
CREATE TABLE catalogue (product_id INTEGER PRIMARY KEY, name TEXT NOT NULL, price_paise INTEGER NOT NULL, tax_pct INTEGER NOT NULL);
INSERT INTO catalogue VALUES
(1, 'Steel tiffin box', 899, 5),
(2, 'Cotton kurta', 99900, 5),
(3, 'Pressure cooker 3 L', 189900, 18),
(4, 'Notebook, 200 pages', 4500, 12);
expected:
columns: [product_id, name, price_with_tax_paise]
rows:
- [1, 'Steel tiffin box', 1049]
- [4, 'Notebook, 200 pages', 5040]
- name: keeps prices that come to exactly 50,000 paise with tax, and rounds a half paisa up
ordered: true
setup: |
CREATE TABLE catalogue (product_id INTEGER PRIMARY KEY, name TEXT NOT NULL, price_paise INTEGER NOT NULL, tax_pct INTEGER NOT NULL);
INSERT INTO catalogue VALUES
(1, 'Brass diya', 210, 5),
(2, 'Ceiling fan', 42373, 18),
(3, 'Wall clock', 47619, 5);
expected:
columns: [product_id, name, price_with_tax_paise]
rows:
- [1, 'Brass diya', 326]
- [2, 'Ceiling fan', 50000]
- [3, 'Wall clock', 50000] A hint
price_paise * (100 + tax_pct) is the price with tax in hundredths of a paisa. Integer division by 100 drops the fraction; adding 50 before dividing rounds half up instead.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Indexing generated columns in each engine, with query plans Pro: opens with a Pro, Premium or Ultimate pass
An index on a generated column lets the database search by a computed value, but each engine decides differently which queries may use it. This part runs the plans in SQLite, measures the space each kind takes, and sets out the rules of PostgreSQL and MySQL.
In this part:
- A unique key on a normalised email, run in SQLite
- Which queries use the index, engine by engine
- A field from a JSON document, indexed
- Stored or virtual: the space, measured
- MySQL functional indexes are generated columns in disguise
3 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Limits, failure cases and money arithmetic, with numbers Pro: opens with a Pro, Premium or Ultimate pass
Generated columns fail in quiet ways: an expression that rounds money badly, a function one engine accepts and another refuses, and a change of expression that rewrites a whole table. This part works through each with numbers.
In this part:
- Rounding money inside the expression, worked through
- What each engine refuses, reproduced
- Changing a generated column later
2 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What is the difference between a STORED and a VIRTUAL generated column, and which kind does each engine create by default?
Both hold a value that the database computes from other columns of the same row, and neither can be written by an insert or an update. They differ in when the value is computed. A STORED column is computed on every insert and on every update of the row, and is kept in the row like any other column, so it costs space and write time but nothing on reads. A VIRTUAL column is computed each time it is read and takes no space, so it costs a little work on every read instead. Without either keyword, SQLite and MySQL create a virtual column, and so does PostgreSQL 18, which added virtual columns; earlier PostgreSQL versions have only stored ones. PostgreSQL 18 also cannot index a virtual column, so a column that needs an index there must be stored.
-
Mid to senior
You added
email_key text GENERATED ALWAYS AS (lower(email)) STOREDto a PostgreSQL users table, with a unique index on it. Logins are still slow, and EXPLAIN shows a sequential scan of users. Why, and what are your options? -
Senior to staff
A new report groups a 300-million-row PostgreSQL 18 orders table by a value computed from two of its columns. A teammate wants to add a STORED generated column with an index tonight. What would that cost, and what would you do instead?
-
Mid to senior
A teammate keeps
orders.total_paiseup to date with a trigger on order_items, and asks whether a generated column could replace the trigger. What do you answer, and what are the options for keeping such a total correct?
Key takeaways
- A generated column is computed by the database from the other columns of its row, and can never be written.
- STORED columns are computed on every write and take space; VIRTUAL ones are computed on every read and take none.
- Generation expressions use only the row itself and functions whose result never changes.
- SQLite and MySQL use VIRTUAL by default; PostgreSQL 18 does too, and earlier versions offer only STORED.
References
- PostgreSQL 18: Generated Columns (The PostgreSQL Global Development Group)
- PostgreSQL 18 Release Notes (The PostgreSQL Global Development Group)
- PostgreSQL 18: ALTER TABLE (SET EXPRESSION, DROP EXPRESSION) (The PostgreSQL Global Development Group)
- SQLite: Generated Columns (SQLite)
- SQLite: EXPLAIN QUERY PLAN (SQLite)
- MySQL 8.4 Reference Manual: CREATE TABLE and Generated Columns (Oracle Corporation)
- MySQL 8.4 Reference Manual: Secondary Indexes and Generated Columns (Oracle Corporation)
- MySQL 8.4 Reference Manual: CREATE INDEX (functional key parts) (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress