Skip to main content
    Back

    Advanced Track • Views

    Advanced Views & Virtual Tables

    By the end of this lesson you'll know exactly which views you can write through and which you can't — and how to fix the ones you can't. You'll build updatable views guarded by WITH CHECK OPTION, lock data down with security views, and make a complex join view writable with an INSTEAD OF trigger.

    What You'll Learn

    • Which views are auto-updatable (and the exact rules)
    • Guard writes with WITH CHECK OPTION so rows can't escape
    • Build security views that hide columns and rows
    • Use security_invoker / security-barrier to stop data leaks
    • Know why join & aggregate views are read-only
    • Make complex views writable with INSTEAD OF triggers

    Our Sample Tables: products & orders

    Every example uses this products table — plus a small orders table for the join examples. Knowing the data is half of writing good SQL.

    Result:

    idproduct_namecategorypricestock
    1Wireless MouseElectronics24.99120
    2Coffee MugKitchen9.5300
    3Mechanical KeyboardElectronics7945
    4NotebookStationery3.25500
    5Desk LampHome3280
    6USB-C CableElectronics12.99200

    And a tiny orders table — each row links to a product by product_id:

    Result:

    idproduct_idquantityorder_date
    101122026-06-10
    102312026-06-11
    103652026-06-12

    1. Updatable Views & the Auto-Updatable Rules

    A view is a saved SELECT that behaves like a virtual table — it stores no data and re-runs its query every time you read it. The surprise for many people: you can also write through a view, and the change lands in the real table underneath. PostgreSQL allows this automatically when the view is simple enough to map each result row back to exactly one base-table row.

    🪟 Real-world analogy

    An updatable view is a window onto a table. You can reach through the window to move things on the shelf behind it — but only if the window shows one clear shelf. A view that summarises many shelves (an aggregate) is more like a security camera: great for looking, impossible to reach through.

    A view is auto-updatable only when it: reads exactly one table in FROM; has no GROUP BY, HAVING, DISTINCT, LIMIT, OFFSET, UNION/INTERSECT/EXCEPT; and uses no aggregate or window functions and no sub-query in the SELECT list. Break any of those and the view turns read-only.

    A simple, auto-updatable view

    Create a single-table view and read from it.

    Try it Yourself »
    SQL
    -- A view is a saved SELECT that behaves like a virtual table.
    -- It stores no data of its own — it re-runs its query every time.
    CREATE VIEW v_in_stock AS
    SELECT id, product_name, category, price, stock
    FROM products
    WHERE stock > 0;
    
    -- Query it exactly like a table:
    SELECT product_name, stock FROM v_in_stock;
    
    -- Because this view reads ONE table and adds no GROUP BY,
    -- DISTINCT, or aggregates, the database marks it "auto-updatable":
    -- you can INSERT / UPDATE / DELETE through it.

    Result — v_in_stock — all 6 products have stock > 0:

    product_namestock
    Wireless Mouse120
    Coffee Mug300
    Mechanical Keyboard45

    Because v_in_stock reads one table with no grouping, the database lets you write through it. Each statement below changes the real products row:

    Writing through a view

    UPDATE, INSERT, and DELETE pass through to products.

    Try it Yourself »
    SQL
    -- UPDATE through the view — it rewrites the real products row.
    UPDATE v_in_stock
    SET price = 21.99
    WHERE product_name = 'Wireless Mouse';
    
    -- INSERT through the view — the new row lands in products.
    INSERT INTO v_in_stock (product_name, category, price, stock)
    VALUES ('Laptop Stand', 'Home', 39.00, 50);
    
    -- DELETE through the view — removes the real row too.
    DELETE FROM v_in_stock WHERE product_name = 'Notebook';
    
    -- The view is just a window onto products; every write
    -- passes straight throug
    ...

    2. WITH CHECK OPTION — Stop Rows From Escaping

    A filtered view has a sharp edge. v_in_stock only shows rows where stock > 0, but by default you can still INSERT a row with stock = 0 through it. The insert succeeds, then the new row instantly disappears from the view because it fails the filter — a baffling "I just added it, where did it go?" bug.

    WITH CHECK OPTION closes that gap. It tells the database to apply the view's WHERE to writes as well as reads, rejecting any INSERT or UPDATE that would produce a row the view can't see.

    WITH CHECK OPTION

    Reject writes that would push a row out of the view.

    Try it Yourself »
    SQL
    -- Rebuild the view WITH CHECK OPTION so the filter is enforced
    -- on writes, not just reads.
    CREATE VIEW v_in_stock AS
    SELECT id, product_name, category, price, stock
    FROM products
    WHERE stock > 0
    WITH CHECK OPTION;   -- 👈 the new safety rule
    
    -- This INSERT is REJECTED: stock = 0 fails the view's WHERE,
    -- so the row would instantly vanish from the view.
    INSERT INTO v_in_stock (product_name, category, price, stock)
    VALUES ('Backordered Pen', 'Stationery', 2.00, 0);
    -- ERROR: new row violates 
    ...

    Your Turn: a guarded updatable view

    Fill in the blanks to create an updatable view of budget products (price < 20) that refuses to let a row be priced out of itself. The expected behaviour is in the comments so you can check yourself.

    🎯 Your Turn: WITH CHECK OPTION

    Build a filtered, write-guarded view.

    Try it Yourself »
    SQL
    -- 🎯 YOUR TURN — fill in the two blanks, then press "Try it Yourself"
    -- Goal: an updatable view of only the cheap products (price < 20),
    --       that REFUSES to let a row be priced out of the view.
    
    CREATE VIEW v_budget AS
    SELECT id, product_name, category, price, stock
    FROM products
    WHERE ___          -- 👉 the filter: price under 20
    WITH ___ OPTION;   -- 👉 the keyword that enforces the filter on writes
    
    -- ✅ Expected: the view is created. Then this write is BLOCKED,
    --    because price 25 
    ...

    3. Views Over Joins & Aggregates Are Read-Only

    The moment a view summarises data, writing through it stops making sense. If v_category_stats shows one row per category with an average price, what would it even mean to UPDATE that average? The database can't split one summary row back into the individual rows it came from, so it refuses.

    The same logic applies to most join views: a single result row stitches together columns from several tables, and an ambiguous write can't be safely undone. PostgreSQL makes a simple join view read-only by default — you'll make it writable in Section 5 with a trigger.

    An aggregate view (read-only)

    GROUP BY makes a view non-updatable.

    Try it Yourself »
    SQL
    -- Views over a JOIN or an aggregate are READ-ONLY.
    -- The database can't reverse one summary row back into the
    -- individual rows it came from, so writes are refused.
    CREATE VIEW v_category_stats AS
    SELECT category,
           COUNT(*)     AS items,
           ROUND(AVG(price), 2) AS avg_price,
           SUM(stock)   AS total_stock
    FROM products
    GROUP BY category;
    
    SELECT * FROM v_category_stats ORDER BY category;
    
    -- Try to write through it and you get:
    UPDATE v_category_stats SET avg_price = 0 WHERE cat
    ...

    Result — v_category_stats:

    categoryitemsavg_pricetotal_stock
    Electronics338.99365
    Home13280
    Kitchen19.5300
    Stationery13.25500

    A join view (read-only for now)

    Join products + orders into one virtual table.

    Try it Yourself »
    SQL
    -- A view over a JOIN of products + orders. Reading is fine;
    -- writing needs an INSTEAD OF trigger (next section).
    CREATE VIEW v_order_lines AS
    SELECT o.id          AS order_id,
           o.order_date,
           p.product_name,
           o.quantity,
           p.price,
           o.quantity * p.price AS line_total
    FROM orders o
    JOIN products p ON p.id = o.product_id;
    
    SELECT order_id, product_name, quantity, line_total
    FROM v_order_lines
    ORDER BY order_id;

    Result — v_order_lines:

    order_idproduct_namequantityline_total
    101Wireless Mouse249.98
    102Mechanical Keyboard179
    103USB-C Cable564.95

    4. Security Views — Hide Columns and Rows

    A view is a clean way to expose part of a table. You lock down the base table, then grant access only to a view that selects the safe columns and rows. Callers can query the view but never touch the data it hides — costs, supplier IDs, other people's records.

    Two safety switches matter. security_invoker = true (PostgreSQL 15+) runs the view with the caller's privileges, so any row-level security on the base table still applies to them; without it a view runs as its owner and can hand back rows the caller was never permitted to see. The older security-barrier view (WITH (security_barrier = true)) forces the view's own WHERE to run before any function the caller injects, so a cleverly-crafted predicate can't peek at filtered-out rows.

    A column-hiding security view

    Expose safe columns; lock the base table.

    Try it Yourself »
    SQL
    -- SECURITY VIEW: expose only safe COLUMNS, hide cost & supplier.
    -- Staff query the view; the base table stays locked down.
    CREATE VIEW v_public_catalog AS
    SELECT id, product_name, category, price   -- no cost, no supplier_id
    FROM products;
    
    -- Lock the real table, open only the view:
    REVOKE ALL ON products       FROM sales_team;
    GRANT  SELECT ON v_public_catalog TO sales_team;
    
    -- security_invoker (PostgreSQL 15+): run the view with the
    -- CALLER's privileges, so row-level security on products
    ...

    Result — v_public_catalog — cost & supplier never appear:

    idproduct_namecategoryprice
    1Wireless MouseElectronics24.99
    2Coffee MugKitchen9.5

    Your Turn: a security view (columns + rows)

    Build a view that exposes only product_name and price (hiding stock and cost) and only the Electronics rows, running with the caller's own permissions. Fill in the three blanks.

    🎯 Your Turn: security view

    Limit columns and rows; use security_invoker.

    Try it Yourself »
    SQL
    -- 🎯 YOUR TURN — fill in the three blanks, then press "Try it Yourself"
    -- Goal: a security view that exposes ONLY name + price (hide stock/cost)
    --       and ONLY the Electronics rows. Make it respect the caller's
    --       own permissions with security_invoker.
    
    CREATE VIEW v_electronics_public
    WITH (___ = true) AS              -- 👉 run as the caller, not the owner
    SELECT product_name, ___          -- 👉 the one extra safe column to show
    FROM products
    WHERE category = ___;             -- 👉 l
    ...

    5. INSTEAD OF Triggers — Make Complex Views Writable

    What if you genuinely need to write through a join view like v_order_lines? You take control of the write yourself with an INSTEAD OF trigger. The name is literal: instead of the database's default (which is "error, this view is read-only"), it runs your function, and your function writes to the real tables however you decide.

    Below, an INSTEAD OF INSERT trigger reads the incoming row, looks up the product's id from the name the view exposed, and inserts a proper row into orders. From the caller's side it looks like an ordinary insert into the view.

    INSTEAD OF INSERT trigger

    Intercept a write to the join view and route it to orders.

    Try it Yourself »
    SQL
    -- INSTEAD OF trigger: make the read-only JOIN view writable.
    -- The trigger intercepts the INSERT and writes to the REAL table.
    CREATE OR REPLACE FUNCTION fn_insert_order_line()
    RETURNS TRIGGER LANGUAGE plpgsql AS $$
    DECLARE
        v_product_id INT;
    BEGIN
        -- Look up the product id from the name the view exposed:
        SELECT id INTO v_product_id
        FROM products WHERE product_name = NEW.product_name;
    
        -- Insert into the underlying orders table:
        INSERT INTO orders (product_id, quantity, 
    ...

    6. View vs. Materialized View — A Quick Recap

    Everything above is a plain view: zero stored data, always live, re-computed on every read. That's perfect when freshness matters and the query is cheap. But when a view's query is expensive (big joins, heavy aggregates) and you read it far more often than the data changes, re-running it every time is wasteful.

    A materialized view trades freshness for speed: it runs the query once and stores the result like a cached table, which you refresh with REFRESH MATERIALIZED VIEW. Reads become as fast as reading a table; the cost is that the data is only as current as the last refresh — and materialized views are not updatable.

    Common Errors (and the fix)

    • "cannot update view … contains GROUP BY": you tried to write through an aggregate (or join) view. Those are read-only — summarised rows can't be mapped back to base rows. Add an INSTEAD OF trigger if you truly need the write.
    • The "disappearing insert": you INSERT through a filtered view and the new row never shows up. The row was created but fails the view's WHERE. Recreate the view WITH CHECK OPTION so the bad write is rejected loudly instead.
    • "new row violates check option for view": that's WITH CHECK OPTION working as intended — your INSERT/UPDATE would have produced a row outside the view. Change the value so it matches the filter, or write to the base table directly.
    • Security bypassed: you built a view to hide rows but a user still sees everything. They likely retain SELECT on the base table — REVOKE it. For row rules, add security_invoker/security_barrier so the optimizer can't leak filtered-out rows.
    • "INSTEAD OF triggers cannot have WHEN conditions" / silent no-op: an INSTEAD OF trigger must be FOR EACH ROW, and you need one per operation you support (INSERT/UPDATE/DELETE). Operations without a trigger simply do nothing.

    📘 Quick Reference

    Syntax / conceptWhat it does
    CREATE VIEW v AS SELECT …Define a virtual table from a query
    Auto-updatableOne table, no GROUP BY/DISTINCT/aggregate → writable
    WITH CHECK OPTIONReject writes that fall outside the view's WHERE
    GROUP BY / JOIN viewRead-only by default
    WITH (security_invoker = true)Run the view as the caller, not the owner
    WITH (security_barrier = true)Run the view's WHERE before caller functions
    INSTEAD OF INSERT/UPDATE/DELETECustom DML logic for non-updatable views
    MATERIALIZED VIEWStored, cached result; refresh to update

    Frequently Asked Questions

    Q: How do I know if a view is updatable before I try?

    Check the rules: one table in FROM, and no GROUP BY, HAVING, DISTINCT, LIMIT/OFFSET, set operations, aggregates, or window functions. In PostgreSQL you can also query information_schema.views — the is_updatable column says yes or no.

    Q: Does WITH CHECK OPTION slow down reads?

    No. It only affects INSERT and UPDATE, adding a check that the resulting row still matches the view. Reads are unaffected, so there's almost never a reason to leave it off a filtered updatable view.

    Q: Is hiding a column in a view enough to keep it secret?

    Only if the caller can't read the base table. A view doesn't remove anyone's existing table privileges — you must REVOKE on the table and GRANT on the view. For row-level hiding, add security_invoker or security_barrier.

    Q: View or materialized view for a slow dashboard query?

    If the numbers can be a few minutes stale and you read them constantly, a materialized view is far faster — query once, store, REFRESH on a schedule. If you need always-live data or want to write through it, keep a plain view.

    Mini-Challenge: A Self-Guarding Low-Stock View

    Put it together — a brief, a blank canvas, and the expected behaviour in the comments. Write it, then copy it into a PostgreSQL playground to confirm.

    🎯 Mini-Challenge

    An updatable v_low_stock view guarded by WITH CHECK OPTION.

    Try it Yourself »
    SQL
    -- 🎯 MINI-CHALLENGE
    -- Using ONLY what this lesson covered (CREATE VIEW, a single-table
    -- filter, and WITH CHECK OPTION):
    --   1. Create an updatable view v_low_stock of products with stock < 100
    --      exposing id, product_name, category, price, stock
    --   2. Add WITH CHECK OPTION so a row can never be restocked OUT of the view
    --
    -- ✅ Expected: view created. Then this write is BLOCKED:
    --    UPDATE v_low_stock SET stock = 500 WHERE product_name = 'Desk Lamp';
    --    -- ERROR: new row violate
    ...

    🎉 Lesson Complete

    • ✅ A single-table view with no grouping is auto-updatable — writes pass through to the base table
    • WITH CHECK OPTION rejects writes that would push a row out of the view
    • ✅ Join and aggregate views are read-only because summary rows can't be reversed
    • ✅ Real security = REVOKE on the table + GRANT on the view, plus security_invoker/security_barrier for row rules
    • INSTEAD OF triggers make complex views writable with your own logic
    • Next: Analytical SQL — window functions and BI-grade queries

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service