Updatable vs Read-Only Views
Use this lesson to decide whether a PostgreSQL view can support INSERT/UPDATE/DELETE, enforce view predicates with WITH CHECK OPTION, and recognize when a view is inherently read-only.
Concept Overview
Not all views behave the same for writes.
In PostgreSQL:
- Many simple views are automatically updatable.
- Views that include joins, aggregations, set operations, or other non-1:1 mappings are typically read-only unless you add
INSTEAD OFtriggers.
Why is it important?
- Correctness: avoid thinking your writes succeeded when they are rejected
- API design: decide whether a view is a read model or a write interface
- Safety:
WITH CHECK OPTIONprevents inserting/updating rows that don't match the view - Maintainability: write rules belong either in base tables or explicit triggers, not implicit assumptions
Where does it fit?
This shows up when you:
- expose views to applications instead of base tables
- use views as a compatibility layer during migrations
- build multi-tenant row-restricted interfaces
Syntax & Rules
Core Syntax
Create a simple (often updatable) view:
CREATE VIEW v_active_users AS
SELECT user_id, email, status
FROM users
WHERE status = 'active';
Enforce the view predicate on writes:
CREATE VIEW v_active_users AS
SELECT user_id, email, status
FROM users
WHERE status = 'active'
WITH CHECK OPTION;
Automatic Updatability (Rule of Thumb)
A view is commonly automatically updatable when:
- it selects from a single base table (or another updatable view)
- it does not use
DISTINCT,GROUP BY, aggregates, window functions - it does not use set operations (
UNION,INTERSECT,EXCEPT) - it does not contain joins
If a view isn't automatically updatable, PostgreSQL typically returns an error and suggests INSTEAD OF triggers.
Available Options / Parameters
| Feature | Example | Meaning / Notes |
|---|---|---|
WITH CHECK OPTION | ... WITH CHECK OPTION | rejects writes that would make the row invisible to the view |
LOCAL / CASCADED | WITH LOCAL CHECK OPTION | how check option behaves with nested views |
INSTEAD OF triggers | CREATE TRIGGER ... INSTEAD OF INSERT | implement custom write logic for complex views |
Key Rules and Considerations
- Even if a view is updatable, you can still make it effectively read-only via privileges (grant only
SELECT). WITH CHECK OPTIONis critical when using views to enforce row restrictions.- If the view includes derived columns (expressions), those columns are not directly writable.
Step-by-Step Examples
Use this setup for the examples:
DROP VIEW IF EXISTS v_users_active;
DROP VIEW IF EXISTS v_customer_orders;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS customers;
CREATE TABLE users (
user_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
status text NOT NULL
);
INSERT INTO users (email, status) VALUES
('a@example.com', 'active'),
('b@example.com', 'inactive');
CREATE TABLE customers (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(customer_id),
amount numeric(12,2) NOT NULL CHECK (amount >= 0)
);
INSERT INTO customers (email) VALUES ('c@example.com');
INSERT INTO orders (customer_id, amount) VALUES (1, 10.00);
Example 1: An Automatically Updatable View (Beginner)
CREATE VIEW v_users_active AS
SELECT user_id, email, status
FROM users
WHERE status = 'active';
UPDATE v_users_active
SET email = 'a2@example.com'
WHERE user_id = 1;
SELECT user_id, email, status
FROM users
ORDER BY user_id;
Expected output:
CREATE VIEW
UPDATE 1
user_id | email | status
---------+------------------+----------
1 | a2@example.com | active
2 | b@example.com | inactive
(2 rows)
Explanation:
- The view maps 1:1 to rows in
users, so PostgreSQL can update the base table.
Example 2: A Join View is Read-Only by Default (Intermediate)
CREATE VIEW v_customer_orders AS
SELECT c.customer_id, c.email, o.order_id, o.amount
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id;
UPDATE v_customer_orders
SET amount = 99.00
WHERE order_id = 1;
Expected outcome:
CREATE VIEW
ERROR: cannot update view "v_customer_orders"
DETAIL: Views that do not select from a single table or view are not automatically updatable.
HINT: To enable updating the view, provide an INSTEAD OF UPDATE trigger or an unconditional ON UPDATE DO INSTEAD rule.
Explanation:
- The join means a view row does not map cleanly to exactly one underlying table row.
Example 3: Enforce the View Predicate With WITH CHECK OPTION (Advanced)
DROP VIEW IF EXISTS v_users_active;
CREATE VIEW v_users_active AS
SELECT user_id, email, status
FROM users
WHERE status = 'active'
WITH CHECK OPTION;
INSERT INTO v_users_active (email, status)
VALUES ('x@example.com', 'inactive');
Expected outcome:
DROP VIEW
CREATE VIEW
ERROR: new row violates check option for view "v_users_active"
DETAIL: Failing row contains (...).
Fix:
INSERT INTO v_users_active (email, status)
VALUES ('x@example.com', 'active');
SELECT user_id, email, status
FROM v_users_active
ORDER BY user_id;
Expected output:
INSERT 0 1
user_id | email | status
---------+------------------+--------
1 | a2@example.com | active
3 | x@example.com | active
(2 rows)
Explanation:
- Without
WITH CHECK OPTION, an insert/update could create a row that immediately "disappears" from the view.
Practical Use Cases
1) Active-Only Interfaces
Context: present only active rows to applications.
CREATE VIEW v_active_users AS
SELECT * FROM users WHERE status = 'active'
WITH CHECK OPTION;
2) Multi-Tenant Row Restrictions
Context: build per-tenant views (often paired with permissions).
CREATE VIEW v_tenant_42_orders AS
SELECT * FROM orders WHERE tenant_id = 42
WITH CHECK OPTION;
3) Read Models for Reporting
Context: join + aggregate views are often read-only by design.
CREATE VIEW v_daily_revenue AS
SELECT date_trunc('day', created_at) AS day, SUM(amount)
FROM orders
GROUP BY 1;
Common Mistakes & Troubleshooting
Mistake 1: Expecting Join Views to Be Updatable
What happens: PostgreSQL throws an error and suggests INSTEAD OF triggers.
Fix: write to base tables, or implement an explicit INSTEAD OF trigger when appropriate.
Mistake 2: Forgetting WITH CHECK OPTION
What happens: inserts/updates through the view can produce rows that do not satisfy the view predicate.
Fix: add WITH CHECK OPTION when the view is used as a policy boundary.
Mistake 3: Granting Writes to Views Without Understanding Side Effects
What happens: applications start depending on view write semantics, making migrations harder.
Fix: treat writable views as an API contract, document it, and test it.
Debugging tips:
- If an UPDATE fails, check whether the view is automatically updatable (single-table, no joins/aggregates).
- Inspect view definition with
\d+ view_nameorpg_get_viewdef. - If you need write support, decide between base-table writes vs
INSTEAD OFtriggers. - For policy views, test that writes violating the predicate are rejected.
Best Practices
- ✅ Use updatable views only when they are clearly 1:1 with a base table; ❌ treat complex reporting views as writable.
- ✅ Add
WITH CHECK OPTIONfor policy views; ❌ allow writes that produce invisible rows. - ✅ Prefer writing to base tables for complex cases; ❌ hide business writes in implicit view behavior.
- ✅ Control privileges explicitly; ❌ grant broad table access and hope the view is used.
- ✅ Keep view write contracts stable and tested; ❌ change writable view behavior without coordination.
Hands-On Practice
Use this setup for the exercises:
DROP VIEW IF EXISTS practice_v_active;
DROP TABLE IF EXISTS practice_users;
CREATE TABLE practice_users (
user_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
status text NOT NULL
);
INSERT INTO practice_users (email, status) VALUES
('p1@example.com', 'active'),
('p2@example.com', 'inactive');
Exercise 1 (Easy): Create an updatable view
Task: Create a view practice_v_active that selects only active users.
-- Your SQL here
Solution:
CREATE VIEW practice_v_active AS
SELECT user_id, email, status
FROM practice_users
WHERE status = 'active';
Exercise 2 (Medium): Update through the view
Task: Update the active user's email through the view.
-- Your SQL here
Solution:
UPDATE practice_v_active
SET email = 'p1_new@example.com'
WHERE user_id = 1;
Exercise 3 (Advanced): Add CHECK OPTION
Task: Recreate the view with WITH CHECK OPTION and attempt to insert an inactive row through the view (it should fail).
-- Your SQL here
Solution:
DROP VIEW practice_v_active;
CREATE VIEW practice_v_active AS
SELECT user_id, email, status
FROM practice_users
WHERE status = 'active'
WITH CHECK OPTION;
INSERT INTO practice_v_active (email, status)
VALUES ('bad@example.com', 'inactive');
Connection to Other Concepts
| Concept | Why it matters |
|---|---|
| Privileges | you can make a view read-only by granting only SELECT |
| Triggers | INSTEAD OF triggers enable custom view write behavior |
| Constraints | base table constraints still apply to writes through views |
| Joins and aggregates | often make views read-only by default |
| Migrations | writable views are contracts that must be managed carefully |
Visual Learning Diagram
flowchart TD
A[View Definition] --> B{Single-table\nno joins/aggregates?}
B -->|Yes| C[Automatically updatable]
B -->|No| D[Read-only by default]
D --> E[INSTEAD OF trigger\n(if write needed)]
C --> F[WITH CHECK OPTION\n(optional safety)]
classDef allNodes fill:#3e3e3e,stroke:#ffffff,stroke-width:2px,color:#f5f5f5
classDef highlight fill:#3e3e3e,stroke:#ffffff,stroke-width:4px,color:#f5f5f5
class A,B,C,D,E,F allNodes
class C highlight
Common Pitfalls
| Pitfall | Consequence | Prevention |
|---|---|---|
| Join view writes | errors at runtime | write to base tables or add INSTEAD OF trigger |
Missing WITH CHECK OPTION | rows disappear from view after insert/update | add check option for policy views |
| Treating writable views as accidental API | migration pain | document and test view write contracts |
| Assuming view bypasses constraints | inserts fail unexpectedly | remember base constraints still apply |
| Over-granting base table access | view policy ineffective | grant on views, restrict base tables |
Quick Reference
CREATE VIEW v AS SELECT ...
CREATE VIEW v AS SELECT ... WITH CHECK OPTION
UPDATE v SET ... WHERE ...
ERROR: cannot update view (join/aggregate) -> use INSTEAD OF triggers
GRANT SELECT ON v TO role
What's Next
- Previous: Creating and Managing Views - Build and evolve view definitions.
- Continue to 13. Stored Procedures and Functions - Encapsulate logic in the database.
- Module Overview - Return to Views overview.