Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: Two generated columns at work · STORED or VIRTUAL · Where generated columns help · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

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

A virtual line total and a stored tax amount SQL · line_totals.sql
-- 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

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.

A STORED column is computed on every write and kept in the row; a VIRTUAL column is computed on every read from the row.INSERT or UPDATE writesunit_price_paise and qtySTORED: tax_paise iscomputed now andwritten into the rowThe row on disk:unit_price_paise, qty,tax_paiseSELECT reads the rowVIRTUAL: line_total_paiseis computed now, fromthe row just readThe result hasevery columnon every writelateron every read

When each kind of generated column is computed

Text description of the diagram

The diagram follows one order line from top to bottom.

  1. An INSERT or UPDATE writes the ordinary columns unit_price_paise and qty.
  2. The STORED generated column tax_paise is computed at that moment and written into the row, on every write that changes the row.
  3. The row on disk holds unit_price_paise, qty and tax_paise. It does not hold the virtual column.
  4. Later, a SELECT reads the row.
  5. The VIRTUAL generated column line_total_paise is computed at that moment, from the row just read, on every read.
  6. The result contains every column, stored and virtual alike.

STORED or VIRTUAL

The two kinds of generated column
Criterion STOREDVIRTUAL
Computed On every insert, and on every update of the rowOn every read of the column
Space in the row Yes, like any other columnNone
Can be indexed Yes, in all three enginesIn SQLite and MySQL; not in PostgreSQL 18
Adding one to a table that has rows SQLite refuses it; PostgreSQL rewrites the table; MySQL copies itQuick in all three: nothing is written
The default when neither is named No engineSQLite, MySQL and PostgreSQL 18
When to choose The column is read far more often than written, or an engine needs it stored to index itThe 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 NULL and CHECK, 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.

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.

  1. Question 1 of 3 Which kind of generated column takes no space in the row?

    Choose one answer.

    Show the answer to question 1

    Answer: VIRTUAL

    A virtual column is computed from the row every time it is read, so nothing extra is written. A stored column is computed on every write and kept in the row like an ordinary column.

  2. Question 2 of 3 Which of these expressions may a generated column use in SQLite, PostgreSQL and MySQL alike?

    Choose one answer.

    Show the answer to question 2

    Answer: unit_price_paise * qty

    A generation expression uses only the row's own columns and functions whose result never changes. A product of two columns qualifies; random(), a subquery and the current time are refused, or in SQLite make every insert fail.

  3. Question 3 of 3 In PostgreSQL 18, a table declares total bigint GENERATED ALWAYS AS (price_paise * qty), naming neither STORED nor VIRTUAL. What kind of column is it?

    Choose one answer.

    Show the answer to question 3

    Answer: Virtual, computed when read

    PostgreSQL 18 added virtual generated columns and made them the default, as SQLite and MySQL already do. Before version 18, PostgreSQL had only stored generated columns and required the keyword STORED.

GST Calculator (India) Check a price with tax by hand, and compare the rounding with your generated column.

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.

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

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

  2. Mid to senior

    You added email_key text GENERATED ALWAYS AS (lower(email)) STORED to 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    A teammate keeps orders.total_paise up 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.