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 14 – Views, functions, procedures and triggers

Views and updatable views

What a view stores and what it does not, changing views safely, writing through updatable views in PostgreSQL and MySQL, and INSTEAD OF triggers in SQLite.

  • Intermediate
  • 25 minutes
  • Examples run with sql.js 1.14.2 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create views that give a query a name and reuse it
  • Predict which views PostgreSQL, MySQL and SQLite let you write through
  • Write through a SQLite view with INSTEAD OF triggers
  • Use views to limit which rows and columns other users can see

Before you start

What you’ll learn · Before you start

What you will learn

  • Create views that give a query a name and reuse it
  • Predict which views PostgreSQL, MySQL and SQLite let you write through
  • Write through a SQLite view with INSTEAD OF triggers
  • Use views to limit which rows and columns other users can see

Before you start

On this page

A view is a query with a name. CREATE VIEW active_customers AS SELECT … stores the query in the database, and from then on active_customers can be read like a table: in FROM, in a join, in a subquery. A view stores no rows. Each time something reads it, the engine runs the view’s query against the tables underneath, so a view always shows the current data and costs exactly what its query costs. Views give a long join or filter one short name, show some users only some columns or rows, and keep an old table name working after the schema changes. PostgreSQL, MySQL and SQLite all have them; they differ in how a view is changed and in whether rows can be written through it. PostgreSQL and MySQL let you insert, update and delete through simple views. SQLite views are read-only, unless INSTEAD OF triggers say what a write means.

You can run the SQLite examples on this page in your browser (sql.js 1.14.2, which is SQLite 3.49.1). The others were run on disposable local servers, PostgreSQL 18.6 and MySQL 8.4.11, and show the output recorded there.

A view is a stored query

Six customers of a small online shop, and a view of the ones whose account is open:

An active_customers view, read before and after a new sign-up SQL · sqlite/active_customers.sql
-- Six customers of a small online shop; active = 0 marks a closed account.
CREATE TABLE customers (
  id     INTEGER PRIMARY KEY,
  name   TEXT    NOT NULL,
  city   TEXT    NOT NULL,
  phone  TEXT,
  active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
);
INSERT INTO customers VALUES
  (1, 'Asha',     'Pune',     '9800000001', 1),
  (2, 'Farhan',   'Lucknow',  '9800000002', 0),
  (3, 'Meera',    'Kochi',    NULL,         1),
  (4, 'Joseph',   'Pune',     '9800000004', 1),
  (5, 'Tara',     'Jaipur',   '9800000005', 0),
  (6, 'Gurpreet', 'Ludhiana', '9800000006', 1);

-- The view keeps this query under a name. It keeps no rows.
CREATE VIEW active_customers AS
SELECT id, name, city
FROM customers
WHERE active = 1;

SELECT * FROM active_customers ORDER BY id;

-- A new customer signs up. Nothing refreshes the view: the next read runs its query again.
INSERT INTO customers (id, name, city, phone) VALUES (7, 'Lakshmi', 'Madurai', '9800000007');
SELECT * FROM active_customers ORDER BY id;

-- All that SQLite stores for the view: its name and its SQL text.
SELECT type, name, sql FROM sqlite_schema WHERE name = 'active_customers';

Output

┌────┬──────────┬──────────┐
│ id │   name   │   city   │
├────┼──────────┼──────────┤
│ 1  │ Asha     │ Pune     │
│ 3  │ Meera    │ Kochi    │
│ 4  │ Joseph   │ Pune     │
│ 6  │ Gurpreet │ Ludhiana │
└────┴──────────┴──────────┘
┌────┬──────────┬──────────┐
│ id │   name   │   city   │
├────┼──────────┼──────────┤
│ 1  │ Asha     │ Pune     │
│ 3  │ Meera    │ Kochi    │
│ 4  │ Joseph   │ Pune     │
│ 6  │ Gurpreet │ Ludhiana │
│ 7  │ Lakshmi  │ Madurai  │
└────┴──────────┴──────────┘
┌──────┬──────────────────┬─────────────────────────────────┐
│ type │       name       │               sql               │
├──────┼──────────────────┼─────────────────────────────────┤
│ view │ active_customers │ CREATE VIEW active_customers AS │
│      │                  │ SELECT id, name, city           │
│      │                  │ FROM customers                  │
│      │                  │ WHERE active = 1                │
└──────┴──────────────────┴─────────────────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < active_customers.sql

The view returns four of the six customers and three of their five columns. When Lakshmi signs up, the next read of the view includes her. Nothing was refreshed, because there is nothing to refresh: the view keeps no copy of the data. The last query shows everything SQLite keeps for a view, one row in sqlite_schema with its type, its name and the text of its CREATE VIEW statement.

A query names the view, the view is replaced by its stored SELECT, and that SELECT reads the customers table every time.Your querySELECT * FROM active_customersactive_customers, a viewholds: its name and its SELECTrows held: noneWhat runsSELECT id, name, cityFROM customers WHERE active = 1customers, a tablethe rows live hereThe rows of this moment4 customers, then 5 after a sign-upnamesreplaced by its SELECTreadsevery time

A view holds a query, and every read runs it

Text description of the diagram

The diagram follows one read of the view from top to bottom.

  1. Your query says SELECT * FROM active_customers.
  2. active_customers is a view. The database holds its name and its SELECT, and no rows.
  3. The engine reads the view's SELECT in its place, so what runs is SELECT id, name, city FROM customers WHERE active = 1.
  4. That SELECT reads the customers table, where the rows live.
  5. The result is the rows of that moment: 4 customers in the lesson's example, and 5 after a new customer signs up, without any refresh, because every read runs the query again.

A view therefore saves typing, not work. A report that reads active_customers a thousand times runs its query a thousand times. When reading a summary has to be cheap, the result itself must be stored, which is what the next lesson, on materialized views and summary tables, is about.

Name the columns of a view

A view’s columns come from its query, and the query depends on the tables underneath. Three views of the same customers show what a routine migration does to each kind:

Three views after one column is added and another renamed SQL · sqlite/name_the_columns.sql
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL, phone TEXT);
INSERT INTO customers VALUES (1, 'Asha', 'Pune', '9800000001'), (3, 'Meera', 'Kochi', NULL);

-- Three views of the same columns: with SELECT *, with the table's column names, and with a column list.
CREATE VIEW v_star  AS SELECT * FROM customers;
CREATE VIEW v_plain AS SELECT id, name, city FROM customers;
CREATE VIEW v_named (customer_id, name, city) AS SELECT id, name, city FROM customers;

-- A migration adds a column and renames another.
ALTER TABLE customers ADD COLUMN email TEXT;
ALTER TABLE customers RENAME COLUMN city TO town;

SELECT * FROM v_star;
SELECT * FROM v_plain;
SELECT * FROM v_named;

-- SQLite rewrote the stored SQL of every view that named the column.
SELECT name, sql FROM sqlite_schema WHERE type = 'view' ORDER BY name;

Output

┌────┬───────┬───────┬────────────┬───────┐
│ id │ name  │ town  │   phone    │ email │
├────┼───────┼───────┼────────────┼───────┤
│ 1  │ Asha  │ Pune  │ 9800000001 │       │
│ 3  │ Meera │ Kochi │            │       │
└────┴───────┴───────┴────────────┴───────┘
┌────┬───────┬───────┐
│ id │ name  │ town  │
├────┼───────┼───────┤
│ 1  │ Asha  │ Pune  │
│ 3  │ Meera │ Kochi │
└────┴───────┴───────┘
┌─────────────┬───────┬───────┐
│ customer_id │ name  │ city  │
├─────────────┼───────┼───────┤
│ 1           │ Asha  │ Pune  │
│ 3           │ Meera │ Kochi │
└─────────────┴───────┴───────┘
┌─────────┬───────────────────────────────────────────────────────────────────────────────────────┐
│  name   │                                          sql                                          │
├─────────┼───────────────────────────────────────────────────────────────────────────────────────┤
│ v_named │ CREATE VIEW v_named (customer_id, name, city) AS SELECT id, name, town FROM customers │
│ v_plain │ CREATE VIEW v_plain AS SELECT id, name, town FROM customers                           │
│ v_star  │ CREATE VIEW v_star  AS SELECT * FROM customers                                        │
└─────────┴───────────────────────────────────────────────────────────────────────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < name_the_columns.sql

  • v_star now has five columns. SQLite expands * each time it reads the view, so the new email column appeared, and city turned into town. A program that expected four columns, or read them by position, now gets the wrong data or an error.
  • v_plain kept three columns, but SQLite rewrote its stored query to read town, so the view’s column is now called town as well. A report that asks the view for city fails.
  • v_named still shows customer_id, name and city. The column list after the view’s name fixed the names; only the query underneath changed.

PostgreSQL and MySQL expand * once, when the view is created, and they disagree about what happens next:

The same migration under a SELECT * view in PostgreSQL and MySQL

PostgreSQL 18.6 · pg/columns_after_migration.py

# The migration of the SQLite example, on PostgreSQL 18.6: views keep the columns they had when they were created.
from pg_session import psql

psql("""
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL, phone text);
INSERT INTO customers VALUES (1, 'Asha', 'Pune', '9800000001'), (3, 'Meera', 'Kochi', NULL);
CREATE VIEW v_star AS SELECT * FROM customers;
CREATE VIEW v_plain AS SELECT id, name, city FROM customers;

-- A new column does not reach a SELECT * view: * was expanded when the view was created.
ALTER TABLE customers ADD COLUMN email text;
SELECT * FROM v_star;

-- A renamed column keeps its old name in the views: they now read town AS city.
ALTER TABLE customers RENAME COLUMN city TO town;
SELECT * FROM v_plain;
SELECT pg_get_viewdef('v_plain');

-- A column that a view uses cannot be dropped without CASCADE, which would drop the view as well.
ALTER TABLE customers DROP COLUMN phone;

-- CREATE OR REPLACE VIEW may add columns at the end, never remove or reorder them.
CREATE OR REPLACE VIEW v_plain AS SELECT id, name, town AS city, email FROM customers;
CREATE OR REPLACE VIEW v_plain AS SELECT id, name FROM customers;
""")

Output

CREATE TABLE
INSERT 0 2
CREATE VIEW
CREATE VIEW
ALTER TABLE
 id | name  | city  |   phone    
----+-------+-------+------------
  1 | Asha  | Pune  | 9800000001
  3 | Meera | Kochi | 
(2 rows)

ALTER TABLE
 id | name  | city  
----+-------+-------
  1 | Asha  | Pune
  3 | Meera | Kochi
(2 rows)

   pg_get_viewdef   
--------------------
  SELECT id,       +
     name,         +
     town AS city  +
    FROM customers;
(1 row)

ERROR:  cannot drop column phone of table customers because other objects depend on it
DETAIL:  view v_star depends on column phone of table customers
HINT:  Use DROP ... CASCADE to drop the dependent objects too.
CREATE VIEW
ERROR:  cannot drop columns from view

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 columns_after_migration.py

MySQL 8.4.11 · mysql/columns_after_migration.py

# The same migration on MySQL 8.4.11: the view's columns are frozen, and a dropped column breaks it.
from mysql_session import mysql

mysql("""
CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(40) NOT NULL, city VARCHAR(40) NOT NULL, phone CHAR(10));
INSERT INTO customers VALUES (1, 'Asha', 'Pune', '9800000001'), (3, 'Meera', 'Kochi', NULL);
CREATE VIEW v_star AS SELECT * FROM customers;

-- A new column does not reach the view: its column list was fixed when it was created.
ALTER TABLE customers ADD COLUMN email VARCHAR(80);
SELECT * FROM v_star;

-- Dropping a column the view reads is allowed; the view fails from then on, and CHECK TABLE says so.
ALTER TABLE customers DROP COLUMN phone;
SELECT * FROM v_star;
CHECK TABLE v_star;
""")

Output

+----+-------+-------+------------+
| id | name  | city  | phone      |
+----+-------+-------+------------+
|  1 | Asha  | Pune  | 9800000001 |
|  3 | Meera | Kochi | NULL       |
+----+-------+-------+------------+
ERROR 1356 (HY000) at line 11: View 'shop.v_star' references invalid table(s) or column(s) or function(s) or definer/invoker of view lack rights to use them
+-------------+-------+----------+-------------------------------------------------------------------------------------------------------------------------------+
| Table       | Op    | Msg_type | Msg_text                                                                                                                      |
+-------------+-------+----------+-------------------------------------------------------------------------------------------------------------------------------+
| shop.v_star | check | Error    | View 'shop.v_star' references invalid table(s) or column(s) or function(s) or definer/invoker of view lack rights to use them |
| shop.v_star | check | error    | Corrupt                                                                                                                       |
+-------------+-------+----------+-------------------------------------------------------------------------------------------------------------------------------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 columns_after_migration.py

In both, the new email column never reached v_star. PostgreSQL keeps track of what each view uses: after the rename, v_plain reads town AS city, so its readers still find city, and dropping phone is refused because v_star depends on it. MySQL lets the column go, and from then on every read of v_star fails with error 1356, which CHECK TABLE also reports. The same SELECT * view gives different columns in three engines after one migration. Name the columns, with a list after the view’s name or an alias for each one, and they become a fixed part of what the view promises its readers.

Changing or dropping a view

PostgreSQL and MySQL replace a view in place with CREATE OR REPLACE VIEW. PostgreSQL accepts the new query only if it returns the same columns, with the same names, in the same order and with the same types; it may add columns at the end, and the PostgreSQL run above shows the refusal when a column is left out. Replacing keeps the view’s owner and privileges. MySQL also has ALTER VIEW. SQLite has neither: you run DROP VIEW and then CREATE VIEW, inside one transaction so that no other connection reads the schema between them.

SQLite also checks a view later than the other two engines. It creates a view over a table that does not exist at all:

A view over a missing table, found by a migration SQL · sqlite/checked_late.sql
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL);
INSERT INTO customers VALUES (1, 'Asha', 'Pune'), (3, 'Meera', 'Kochi');

-- SQLite accepts a view over a table that does not exist (a typo, or a table dropped long ago) ...
CREATE VIEW old_report AS SELECT id, name FROM customers_2025;

-- ... and reports the problem only when the view is read, or when any table is altered:
SELECT * FROM old_report;
ALTER TABLE customers RENAME COLUMN city TO town;

-- Dropping the broken view lets the migration run.
DROP VIEW old_report;
ALTER TABLE customers RENAME COLUMN city TO town;

-- A view that names a column keeps that column from being dropped.
CREATE VIEW towns AS SELECT DISTINCT town FROM customers;
ALTER TABLE customers DROP COLUMN town;
SELECT * FROM towns ORDER BY town;

Output (exit status 1)

┌───────┐
│ town  │
├───────┤
│ Kochi │
│ Pune  │
└───────┘

Printed as an error (standard error)

Parse error near line 8: no such table: main.customers_2025
Runtime error near line 9: error in view old_report: no such table: main.customers_2025
Runtime error near line 17: error in view towns after drop column: no such column: town

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < checked_late.sql

SQLite created old_report without a complaint, perhaps from a typo or a table removed long ago. Reading it fails, and so does the ALTER TABLE on customers, although old_report never reads customers: SQLite checks the views of the whole schema when it renames or drops a column. Once the broken view is gone, the rename works. The last error is a useful one. A view that names a column keeps that column from being dropped, as in PostgreSQL.

Writing through a view

An INSERT, UPDATE or DELETE on a view must become a change to rows of a table, and the engine can only do that when every row of the view stands for exactly one row of one table.

  • PostgreSQL makes a view automatically updatable when its FROM has exactly one table or updatable view and it has no WITH, DISTINCT, GROUP BY, HAVING, LIMIT, OFFSET, UNION, INTERSECT or EXCEPT at the top level and no aggregate, window function or set-returning function in its select list. A computed column, such as upper(name), stays read-only, while the plain columns beside it can still be written.
  • MySQL asks for the same one-to-one relationship. A view with an aggregate, DISTINCT, GROUP BY, HAVING, UNION, a subquery in its select list or ALGORITHM = TEMPTABLE is not updatable. A view that joins tables with an inner join can take an UPDATE of one table’s columns and an INSERT into one table, but never a DELETE.
  • SQLite never writes through a view by itself. Its views are read-only.
Writing through a view in PostgreSQL 18, MySQL 8.4 and SQLite 3.49
Criterion PostgreSQLMySQLSQLite
One table, a WHERE Every write, MERGE tooUPDATE, DELETE; INSERT only if every column without a default is in the viewRead-only
GROUP BY or aggregates Read-onlyRead-onlyRead-only
Two tables joined Read-onlyUPDATE or INSERT on one table; no DELETERead-only
A computed column That column is read-onlyNo INSERT; other columns can be updatedRead-only
Refusing rows it cannot show WITH CHECK OPTIONWITH CHECK OPTIONA check in the trigger
Any view made writable INSTEAD OF triggersNo: views take no triggersINSTEAD OF triggers

The Pune support desk below works through a view that shows only Pune’s customers, first in PostgreSQL and MySQL:

Writes through a view, without and with WITH CHECK OPTION

PostgreSQL 18.6 · pg/check_option.py

# Writing through an automatically updatable view on PostgreSQL 18.6, without and with WITH CHECK OPTION.
from pg_session import psql

psql("""
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL, phone text);
INSERT INTO customers VALUES (1, 'Asha', 'Pune', '9800000001'), (2, 'Farhan', 'Lucknow', NULL), (4, 'Joseph', 'Pune', '9800000004');
CREATE VIEW pune_customers AS SELECT id, name, city, phone FROM customers WHERE city = 'Pune';

-- One table, no grouping: PostgreSQL turns each write on the view into a write on customers.
UPDATE pune_customers SET phone = '9800000011' WHERE id = 1;
-- The WHERE only chooses the rows a write can reach: these two rows leave the view, or never enter it.
UPDATE pune_customers SET city = 'Mumbai' WHERE id = 4;
INSERT INTO pune_customers VALUES (9, 'Kiran', 'Delhi', NULL);
SELECT * FROM pune_customers ORDER BY id;
SELECT * FROM customers ORDER BY id;

-- With CHECK OPTION, a new or changed row must still be one the view shows.
CREATE OR REPLACE VIEW pune_customers AS
SELECT id, name, city, phone FROM customers WHERE city = 'Pune'
WITH CHECK OPTION;
UPDATE pune_customers SET city = 'Mumbai' WHERE id = 1;
INSERT INTO pune_customers VALUES (10, 'Rohit', 'Delhi', NULL);
INSERT INTO pune_customers VALUES (8, 'Rohit', 'Pune', '9800000008');

-- A grouped view is read-only.
CREATE VIEW city_counts AS SELECT city, count(*) AS customers FROM customers GROUP BY city;
UPDATE city_counts SET customers = 0;
""")

Output

CREATE TABLE
INSERT 0 3
CREATE VIEW
UPDATE 1
UPDATE 1
INSERT 0 1
 id | name | city |   phone    
----+------+------+------------
  1 | Asha | Pune | 9800000011
(1 row)

 id |  name  |  city   |   phone    
----+--------+---------+------------
  1 | Asha   | Pune    | 9800000011
  2 | Farhan | Lucknow | 
  4 | Joseph | Mumbai  | 9800000004
  9 | Kiran  | Delhi   | 
(4 rows)

CREATE VIEW
ERROR:  new row violates check option for view "pune_customers"
DETAIL:  Failing row contains (1, Asha, Mumbai, 9800000011).
ERROR:  new row violates check option for view "pune_customers"
DETAIL:  Failing row contains (10, Rohit, Delhi, null).
INSERT 0 1
CREATE VIEW
ERROR:  cannot update view "city_counts"
DETAIL:  Views containing GROUP BY are not automatically updatable.
HINT:  To enable updating the view, provide an INSTEAD OF UPDATE trigger or an unconditional ON UPDATE DO INSTEAD rule.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_option.py

MySQL 8.4.11 · mysql/writable_views.py

# Which writes MySQL 8.4.11 lets through a view: one table, a join, a GROUP BY, and WITH CHECK OPTION.
from mysql_session import mysql

mysql("""
CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(40) NOT NULL, city VARCHAR(40) NOT NULL);
CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT NOT NULL, total_paise INT NOT NULL);
INSERT INTO customers VALUES (1, 'Asha', 'Pune'), (2, 'Farhan', 'Lucknow'), (4, 'Joseph', 'Pune');
INSERT INTO orders VALUES (10, 1, 50000), (11, 4, 70000);

CREATE VIEW pune_customers AS SELECT id, name, city FROM customers WHERE city = 'Pune' WITH CHECK OPTION;
CREATE VIEW customer_orders AS
SELECT c.id, c.name, o.id AS order_id, o.total_paise FROM customers AS c JOIN orders AS o ON o.customer_id = c.id;
CREATE VIEW city_counts AS SELECT city, COUNT(*) AS customers FROM customers GROUP BY city;
SELECT TABLE_NAME, IS_UPDATABLE, CHECK_OPTION FROM information_schema.VIEWS WHERE TABLE_SCHEMA = 'shop' ORDER BY TABLE_NAME;

-- One table with a check option: rows that would leave the view are refused.
UPDATE pune_customers SET name = 'Asha K' WHERE id = 1;
UPDATE pune_customers SET city = 'Mumbai' WHERE id = 4;
INSERT INTO pune_customers VALUES (9, 'Kiran', 'Delhi');

-- A join view: an UPDATE of one table's columns works, of two tables' columns does not, and DELETE never does.
UPDATE customer_orders SET total_paise = 80000 WHERE order_id = 11;
UPDATE customer_orders SET total_paise = 90000, name = 'Joe' WHERE order_id = 11;
DELETE FROM customer_orders WHERE order_id = 10;

-- A GROUP BY view is not updatable, and no view can have a trigger.
UPDATE city_counts SET customers = 0;
CREATE TRIGGER pune_insert BEFORE INSERT ON pune_customers FOR EACH ROW SET NEW.city = 'Pune';
SELECT * FROM customer_orders ORDER BY order_id;
""")

Output

+-----------------+--------------+--------------+
| TABLE_NAME      | IS_UPDATABLE | CHECK_OPTION |
+-----------------+--------------+--------------+
| city_counts     | NO           | NONE         |
| customer_orders | YES          | NONE         |
| pune_customers  | YES          | CASCADED     |
+-----------------+--------------+--------------+
ERROR 1369 (HY000) at line 14: CHECK OPTION failed 'shop.pune_customers'
ERROR 1369 (HY000) at line 15: CHECK OPTION failed 'shop.pune_customers'
ERROR 1393 (HY000) at line 19: Can not modify more than one base table through a join view 'shop.customer_orders'
ERROR 1395 (HY000) at line 20: Can not delete from join view 'shop.customer_orders'
ERROR 1288 (HY000) at line 23: The target table city_counts of the UPDATE is not updatable
ERROR 1347 (HY000) at line 24: 'shop.pune_customers' is not BASE TABLE
+----+--------+----------+-------------+
| id | name   | order_id | total_paise |
+----+--------+----------+-------------+
|  1 | Asha K |       10 |       50000 |
|  4 | Joseph |       11 |       80000 |
+----+--------+----------+-------------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 writable_views.py

Without a check option, PostgreSQL accepted all three writes. The view’s WHERE decided which rows the UPDATE could reach, but not what it could change them to, so Joseph moved to Mumbai and out of the view, and Kiran was added in Delhi, where the desk can never see him. WITH CHECK OPTION at the end of CREATE VIEW refuses both kinds of write: a new or changed row must still be one the view shows, or the statement fails, with SQLSTATE 44000 in PostgreSQL and error 1369 in MySQL. LOCAL checks only the conditions of this view; CASCADED, the default in both engines, also checks the views it is built on. The MySQL run also shows the limits of a join view, errors 1393 and 1395, and the grouped view that is not updatable at all, as its IS_UPDATABLE column said beforehand.

INSTEAD OF triggers make a SQLite view writable

An INSTEAD OF trigger runs in place of a write on a view. In its body, NEW holds the row as the statement wrote it to the view and OLD the row as it was, and the trigger decides which tables change. The same desk, in SQLite:

Writing through pune_customers, with a check like WITH CHECK OPTION SQL · sqlite/write_through.sql
CREATE TABLE customers (
  id    INTEGER PRIMARY KEY,
  name  TEXT NOT NULL,
  city  TEXT NOT NULL,
  phone TEXT
);
INSERT INTO customers VALUES
  (1, 'Asha',   'Pune',    '9800000001'),
  (2, 'Farhan', 'Lucknow', '9800000002'),
  (4, 'Joseph', 'Pune',    '9800000004');

-- The Pune support desk works through this view.
CREATE VIEW pune_customers AS
SELECT id, name, city, phone FROM customers WHERE city = 'Pune';

-- 1. Views are read-only in SQLite:
UPDATE pune_customers SET phone = '9800000011' WHERE id = 1;

-- 2. INSTEAD OF triggers turn a write on the view into writes on the table. Each one refuses a row
--    that the view could not show, as WITH CHECK OPTION does in PostgreSQL and MySQL.
CREATE TRIGGER pune_customers_insert INSTEAD OF INSERT ON pune_customers
BEGIN
  SELECT RAISE(ABORT, 'pune_customers: the customer must be in Pune') WHERE NEW.city IS NOT 'Pune';
  INSERT INTO customers (id, name, city, phone) VALUES (NEW.id, NEW.name, NEW.city, NEW.phone);
END;
CREATE TRIGGER pune_customers_update INSTEAD OF UPDATE ON pune_customers
BEGIN
  SELECT RAISE(ABORT, 'pune_customers: the customer must be in Pune') WHERE NEW.city IS NOT 'Pune';
  UPDATE customers SET name = NEW.name, city = NEW.city, phone = NEW.phone WHERE id = OLD.id;
END;

UPDATE pune_customers SET phone = '9800000011' WHERE id = 1;
INSERT INTO pune_customers (id, name, city, phone) VALUES (8, 'Rohit', 'Pune', '9800000008');

-- 3. Writes that would take a row out of the view are refused:
UPDATE pune_customers SET city = 'Mumbai' WHERE id = 4;
INSERT INTO pune_customers (id, name, city, phone) VALUES (9, 'Kiran', 'Delhi', NULL);

SELECT * FROM customers ORDER BY id;

Output (exit status 1)

┌────┬────────┬─────────┬────────────┐
│ id │  name  │  city   │   phone    │
├────┼────────┼─────────┼────────────┤
│ 1  │ Asha   │ Pune    │ 9800000011 │
│ 2  │ Farhan │ Lucknow │ 9800000002 │
│ 4  │ Joseph │ Pune    │ 9800000004 │
│ 8  │ Rohit  │ Pune    │ 9800000008 │
└────┴────────┴─────────┴────────────┘

Printed as an error (standard error)

Parse error near line 17: cannot modify pune_customers because it is a view
Runtime error near line 36: pune_customers: the customer must be in Pune
Runtime error near line 37: pune_customers: the customer must be in Pune

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < write_through.sql

The first UPDATE fails with “cannot modify pune_customers because it is a view”. Once the two triggers exist, the same UPDATE changes Asha’s phone number in customers, and the INSERT adds Rohit. The last two writes are refused: each trigger stops with RAISE(ABORT, …), which ends the statement with that message and undoes what it changed, so Joseph still lives in Pune in the final table. That check does the work of WITH CHECK OPTION, which PostgreSQL applies only to automatically updatable views, never to views that use INSTEAD OF triggers.

Views that limit what people can see

In PostgreSQL and MySQL a view can be the only door to a table. Grant the support desk SELECT on the view and nothing on the table, and the desk sees the view’s rows and columns and nothing else:

A support desk that may read the view but not the table Python · pg/view_privileges.py
# A view as the only door to a table, on PostgreSQL 18.6: the support desk may read the view, not the table.
from pg_session import psql

psql("""
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL, phone text, active boolean NOT NULL);
INSERT INTO customers VALUES (1, 'Asha', 'Pune', '9800000001', true), (2, 'Farhan', 'Lucknow', '9800000002', false),
                             (3, 'Meera', 'Kochi', NULL, true);
CREATE VIEW customer_directory (customer_id, name, city) AS
SELECT id, name, city FROM customers WHERE active;

CREATE ROLE support_desk LOGIN;
GRANT SELECT ON customer_directory TO support_desk;
""")

# The desk reads the view; the view reads customers with its owner's privileges.
psql("""
SELECT * FROM customer_directory ORDER BY customer_id;
SELECT name, phone FROM customers;
""", user="support_desk")

# A security invoker view checks the reader's own privileges on the table instead.
psql("ALTER VIEW customer_directory SET (security_invoker = true);")
psql("SELECT * FROM customer_directory ORDER BY customer_id;", user="support_desk")

Output

CREATE TABLE
INSERT 0 3
CREATE VIEW
CREATE ROLE
GRANT
 customer_id | name  | city  
-------------+-------+-------
           1 | Asha  | Pune
           3 | Meera | Kochi
(2 rows)

ERROR:  permission denied for table customers
ALTER VIEW
ERROR:  permission denied for table customers

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 view_privileges.py

The desk read customer_directory and was refused customers itself. That works because, by default, both engines read the tables under a view with the privileges of the view’s owner: PostgreSQL does so unless the view says otherwise, and MySQL through SQL SECURITY DEFINER, its default. The last statement shows the other setting. A PostgreSQL view with security_invoker = true, possible since PostgreSQL 15, checks the privileges and row-level security policies of the user who reads it, so the desk is refused again; MySQL’s SQL SECURITY INVOKER checks the reader’s privileges in the same way. An invoker view is for convenience, not protection. A PostgreSQL view meant to hide rows should also have the security_barrier option, which makes the view’s own conditions run before any condition a reader adds.

Security

SQLite has no users and no GRANT. Anyone who can open the database file can read every table in it, so a view in SQLite hides columns only from code that chooses to read the view. Protect the file, or keep sensitive columns out of it.

Your turn

Exercise · Easy · SQL

A customer directory view without phone numbers

The support desk of an online shop looks customers up in a directory, but must never see their phone numbers. The table customers has one row per customer:

  • id: the customer's number;
  • name: the customer's name;
  • city: where they live, which may be missing (NULL);
  • phone: their mobile number, which may be missing;
  • active: 1 for an open account and 0 for a closed one.

Create a view named customer_directory with three columns, in this order: customer_id (the customer's id), name and city. It lists the customers whose account is open, and it has no phone column.

Leave the last two statements as they are. The INSERT adds a customer after your view exists, and the final query reads the view, with a kind column that says whether customer_directory is a view or a table. A copy made with CREATE TABLE … AS SELECT would miss the new customer and say table. The order of the rows does not matter.

Starter code · directory.sql

-- Write the view customer_directory here.


-- Leave these two statements as they are: a customer who signs up after the view exists, then the check.
INSERT INTO customers (id, name, city, phone, active) VALUES (100, 'Nisha', 'Nagpur', '9800000100', 1);
SELECT d.*, (SELECT type FROM sqlite_schema WHERE name = 'customer_directory') AS kind
FROM customer_directory AS d;
The sample tests · tests.yaml
# Sample tests: each one fills customers, runs your script and compares what the view returns (in any order), with
# the kind of object customer_directory is.
tests:
  - name: open accounts only, and no phone numbers
    setup: |
      CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT, phone TEXT, active INTEGER NOT NULL);
      INSERT INTO customers VALUES
        (1, 'Asha', 'Pune', '9800000001', 1),
        (2, 'Farhan', 'Lucknow', '9800000002', 0),
        (3, 'Meera', 'Kochi', NULL, 1),
        (4, 'Joseph', 'Pune', '9800000004', 1),
        (5, 'Tara', 'Jaipur', '9800000005', 0);
    expected:
      columns: [customer_id, name, city, kind]
      rows:
        - [1, 'Asha', 'Pune', 'view']
        - [3, 'Meera', 'Kochi', 'view']
        - [4, 'Joseph', 'Pune', 'view']
        - [100, 'Nisha', 'Nagpur', 'view']
  - name: a customer without a city stays in the directory
    setup: |
      CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT, phone TEXT, active INTEGER NOT NULL);
      INSERT INTO customers VALUES
        (7, 'Lakshmi', NULL, '9800000007', 1),
        (8, 'Rohit', 'Indore', NULL, 0),
        (9, 'Kiran', 'Delhi', NULL, 1);
    expected:
      columns: [customer_id, name, city, kind]
      rows:
        - [7, 'Lakshmi', null, 'view']
        - [9, 'Kiran', 'Delhi', 'view']
        - [100, 'Nisha', 'Nagpur', 'view']
  - name: every account closed before the new sign-up
    setup: |
      CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT, phone TEXT, active INTEGER NOT NULL);
      INSERT INTO customers VALUES
        (11, 'Gurpreet', 'Ludhiana', '9800000011', 0),
        (12, 'Sana', 'Srinagar', '9800000012', 0);
    expected:
      columns: [customer_id, name, city, kind]
      rows:
        - [100, 'Nisha', 'Nagpur', 'view']
A hint

A view is created with CREATE VIEW name (column, column, …) AS SELECT …. The list in brackets names the view's columns, so the SELECT can keep the table's own names: choose id, name and city, and keep only the rows whose active is 1.

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

4 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 4 A view active_customers shows the customers whose account is open. A new customer with an open account is then inserted into customers, and nothing else is run. What does the next SELECT * FROM active_customers return?

    Choose one answer.

    Show the answer to question 1

    Answer: The new customer with the others, because every read runs the view's query again

    A view stores its query, not its rows. Each read runs the query against the current contents of customers, so the new customer is there at once. Only a materialized view or a summary table keeps rows that must be refreshed.

  2. Question 2 of 4 Which of these views can PostgreSQL update automatically, without any trigger?

    Choose one answer.

    Show the answer to question 2

    Answer: SELECT id, name, city FROM customers WHERE city = 'Pune'

    An automatically updatable view reads exactly one table and has no GROUP BY, DISTINCT, aggregate or set operation, so each of its rows stands for one row of that table. The grouped, distinct and joined views do not meet those conditions and are read-only unless an INSTEAD OF trigger handles the writes.

  3. Question 3 of 4 In SQLite, UPDATE pune_customers SET phone = '9800000011' WHERE id = 1 runs on a view that has no INSTEAD OF UPDATE trigger. What happens?

    Choose one answer.

    Show the answer to question 3

    Answer: The statement fails with "cannot modify pune_customers because it is a view"

    SQLite views are read-only. SQLite writes through a view only when an INSTEAD OF trigger for that kind of statement exists, and then the trigger's statements run in place of the write.

  4. Question 4 of 4 A view is created as SELECT * FROM orders, and later a column is added to orders. In which engine does the new column show up when the view is read?

    Choose one answer.

    Show the answer to question 4

    Answer: SQLite

    SQLite expands * every time it reads the view, so the view picks up the new column, as the lesson's example shows. PostgreSQL and MySQL expand * once, when the view is created, so their views keep the columns the table had at that moment.

SQLite Online (SQL Playground) Load a CSV file of your own as a table, put a view over it and write through the view with INSTEAD OF triggers; SQLite runs in your browser.

Interview questions

Fresher · What is a view, and does it store any data?

A view is a named query stored in the database: CREATE VIEW active_customers AS SELECT id, name, city FROM customers WHERE active = 1. Other queries read it like a table, but it stores no rows. Every read runs the view’s query against the tables underneath, so the result is always current and the cost is the cost of that query. What the database keeps is the definition: SQLite stores the text of the CREATE VIEW statement, and PostgreSQL and MySQL list their views with their queries in their catalogs. Views are used to give a complex query a stable name, to expose only some rows or columns to some users, and to keep old names working when tables change. A view that stores its rows is a different object, the materialized view of PostgreSQL, which has to be refreshed.

2 to 5 years · Can you insert or update rows through a view?

Sometimes, and it depends on the engine. PostgreSQL makes a view automatically updatable when it reads exactly one table or updatable view and has no DISTINCT, GROUP BY, HAVING, LIMIT, set operation, aggregate or window function; its computed columns stay read-only. MySQL needs a one-to-one relationship between view rows and table rows, and allows an UPDATE of one table, or an INSERT into one, through an inner-join view. SQLite views are always read-only. For anything else, PostgreSQL and SQLite let an INSTEAD OF trigger say what the write means. Even an updatable view lets an UPDATE move a row out of the view, unless WITH CHECK OPTION refuses it.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    The Pune support desk edits customers through a PostgreSQL view, pune_customers, defined with WHERE city = 'Pune'. One agent changes a customer's city to Mumbai through the view, and the customer vanishes from it; another inserts a Delhi customer through it. What allowed this, and how do you prevent it in PostgreSQL, MySQL and SQLite?

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

  2. Mid to senior

    A reporting view is defined as SELECT * FROM orders in PostgreSQL, MySQL and SQLite. A migration adds a column, discount_paise, and then drops an old column, note. What happens to the view in each engine at each step, and how would you change the view and the migration?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A view is a stored query, not stored data: every read runs the query, so the view is always current and costs what the query costs.
  • Name a view’s columns with a column list or aliases. SQLite expands SELECT * each time it reads the view, PostgreSQL and MySQL only when the view is created, and SQLite renames a view’s column along with the table’s.
  • PostgreSQL and MySQL change a view with CREATE OR REPLACE VIEW; SQLite needs DROP VIEW and CREATE VIEW in one transaction, and it accepts a broken view that later blocks ALTER TABLE.
  • Simple one-table views are updatable in PostgreSQL and MySQL; SQLite views are read-only until INSTEAD OF triggers say what a write means.
  • WITH CHECK OPTION refuses writes that would leave the view; in an INSTEAD OF trigger, RAISE(ABORT, …) does the same job.
  • Grant SELECT on a view and not on its table to limit what a user sees; SQLite has no privileges, so there a view only hides columns from code that uses it.

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.