DATABASE ENGINEERING / FIELD GUIDE 01
PostgreSQL
views,
explained.
How queries, permissions, and performance work. Follow one customer query from a view to the tables beneath it.
FROM api.active_customers
WHERE city = 'Stockholm';
view: status = 'active' + caller: city = 'Stockholm'
A stored definition. A permission boundary. A real execution plan.
What can the client see?
View definitions and row policies determine the exposed data.
What may the client do?
Privileges and the security model determine authorized operations.
What does the query cost?
Plans, indexes, data, and concurrency determine the work.
An ordinary PostgreSQL view can expose approved data while its queries still read and consume resources on the underlying tables. Under the default permission model, a client can do this with access to the view alone. A normal read does not become an update because a filter lacks an index. [1, 8]
A support team needs to search customer records. You expose a PostgreSQL view containing a few approved columns and give the support role permission to read it. The application then generates queries with different filters. Some of those filters use columns that have no index.
Can those queries reach the original table? Can they slow the application down? Can a client change the original rows even though it has access only to a view? And what changes when the view uses security_invoker = true?
These questions involve different parts of PostgreSQL. A standard view defines the data exposed by a query. Privileges determine which operations a role may perform. The execution plan determines how PostgreSQL finds the data. A permission boundary does not automatically create a boundary around CPU, memory, or storage work.
This article follows one customer table and one view through those decisions. The examples use the security invoker option introduced in PostgreSQL 15; the references point to PostgreSQL 18 documentation. The small dataset makes permission results easy to understand. It is not a performance benchmark. [28]
Start with one table and two roles
We will use private.customers as the source table and api.active_customers as the interface exposed to clients. The role app_owner owns the objects. The role support_reader represents a client that reads customer data.
For the initial examples, assume that row-level security is disabled and that the client has no additional authority through role membership or grants to PUBLIC. Database connection access is already configured. The owner runs the DDL and grant statements; the client runs the application queries. Later SQL blocks illustrate configuration choices rather than one uninterrupted migration. The companion SQL lab supplies a controlled sequence, including setup and explicit role switching, for a fresh demonstration database.
CREATE TABLE private.customers (
id integer PRIMARY KEY,
name text NOT NULL,
email text NOT NULL,
city text NOT NULL,
status text NOT NULL CHECK (status IN ('active', 'inactive')),
confidential_notes text NOT NULL
);
CREATE VIEW api.active_customers AS
SELECT id, name, email, city
FROM private.customers
WHERE status = 'active';
Suppose the table contains these records. Email addresses and confidential notes are omitted from this display.
| id | name | city | status |
|---|---|---|---|
| 42 | Alice | Stockholm | active |
| 43 | Bob | Stockholm | inactive |
| 44 | Clara | Gothenburg | active |
The view exposes Alice and Clara through four columns. It does not expose status or confidential_notes as output columns. A normal query through the view can filter on city even when it selects only id and name. It cannot reference a hidden column as though that column were part of the view. PostgreSQL checks access to columns used by a query, including columns used in its conditions. [12]
A standard view stores a query definition
An ordinary CREATE VIEW view has no stored result rows. PostgreSQL expands its definition in the parsed query tree and passes the combined query to the planner. This is a structured rewrite, not textual replacement of a table name. [1]
The client might submit:
SELECT id, name, email
FROM api.active_customers
WHERE city = 'Stockholm';
For this simple view, the filtering is conceptually equivalent to:
SELECT id, name, email
FROM private.customers
WHERE status = 'active'
AND city = 'Stockholm';
The view supplies the active-customer restriction, and the client supplies the city restriction. PostgreSQL preserves the view's privilege checks during rewriting. The direct-table example describes equivalent data selection; it does not give the client permission to submit that statement directly. [1, 8]
The planner can combine conditions, use underlying indexes, and remove work that does not contribute to the result when it can prove that doing so is safe. It need not first produce every row of the view and then apply the outer filter. Complex definitions and security restrictions can reduce that freedom. [1]
There is no automatic result cache attached to an ordinary view. However, this does not mean every query performs physical disk reads. PostgreSQL can find pages in shared buffers, and the operating system can also cache file data. An index-only plan may avoid many heap visits when index coverage and row visibility permit it; some plans can avoid a relation scan altogether. Distinguish the source of the data from the physical I/O required by a particular execution. Page caching and prepared-plan caching are also separate from storing a view's result rows. [6, 19, 31, 32]
Nonindexed filters are legal
PostgreSQL does not require a column to have an index before it can appear in WHERE. A query using city = 'Stockholm' can be authorized whether or not city is indexed. Indexes provide possible access paths; they are not permission rules. [3, 9]
Assume that the primary key indexes id, while city has no suitable index.
| Client filter | Possible execution strategy |
|---|---|
WHERE id = 42 |
Locate a candidate with the primary-key index and apply any remaining condition. |
WHERE city = 'Stockholm' |
Examine many rows, potentially with a sequential scan. |
WHERE id = 42 AND city = 'Stockholm' |
Use the id index, then check city on the candidate row. |
These are possibilities, not promises. One unindexed condition does not force a full-table scan if another useful condition narrows the candidates. An index can exist and still be rejected by the planner when a sequential scan is estimated to be cheaper. A three-row table will often be scanned even when an index is available. [3, 5]
Returning a small result does not prove that little work was done. A query can examine millions of candidate rows to return a handful of matches. LIMIT 100 restricts returned rows; it does not guarantee that only 100 rows are examined. A sort, an aggregate, or an unsuccessful search can still require substantial work. [5, 6]
An ordinary view cannot have its own index. Index the underlying table or use a materialized view when stored results are appropriate. An index on one expression does not automatically serve another expression or every search operator. [4]
For example, a common active-customer lookup could justify considering this partial index:
CREATE INDEX customers_active_city_idx
ON private.customers (city)
WHERE status = 'active';
This is an example to evaluate, not a universal recommendation. The planner must establish that the query implies the partial-index condition. Here, status = 'active' is fixed inside the view, so an outer city = $1 condition does not by itself prevent use of this index. An unknown parameter in the predicate that must be proved can prevent eligibility, especially with a generic plan. A filter on lower(email) may instead need a corresponding expression index. Additional indexes also consume space and must be maintained during writes. Choose them from measured query patterns. [3, 25, 26]
Read access can still affect production performance
A query through an ordinary view uses the server resources needed by its execution plan. Broad scans consume CPU and page reads. Joins, sorting, and aggregation can consume working memory and may spill to temporary files. Several concurrent queries can multiply that demand. [21]
The consequence for a production system is straightforward: a role restricted to reading approved data can still submit expensive work. A view does not reserve a separate pool of resources for its callers.
The word "affect" needs a precise meaning here. An ordinary, side-effect-free SELECT does not insert, update, or delete customer records. It can nevertheless increase latency for other requests through resource contention. Do not confuse protection against unauthorized writes with protection against expensive reads.
There is also an SQL-level qualification. A SELECT can invoke a function with side effects, including a function that writes data. Function execution privileges and any SECURITY DEFINER behavior require their own review. An application should not classify arbitrary SQL as harmless simply because its text starts with SELECT. [17]
Granting access only to the view
Under the default view model, the client needs the appropriate privilege on the view. Permissions for the underlying relations are normally checked using the view owner's privileges. This supports a controlled interface over tables the client cannot otherwise read. [8]
The owner can grant:
GRANT USAGE ON SCHEMA api TO support_reader;
GRANT SELECT ON api.active_customers TO support_reader;
Now support_reader can query api.active_customers without a direct grant on private.customers. The view can read that table because app_owner has the required authority.
Grant SELECT deliberately. A grant of ALL includes other applicable privileges; it does not mean "all ways of reading." PostgreSQL distinguishes reading, inserting, updating, deleting, and other operations. [9]
Also inspect the role's effective privileges. PostgreSQL combines applicable direct grants, role membership, and grants to PUBLIC. Revoking a direct grant does not create an explicit denial that overrides authority obtained elsewhere. A role that can assume an owner or privileged role is not restricted by a narrow-looking view grant. [10]
Keep the production owner narrowly authorized for its job. The demonstration uses one owner to keep the examples short, but a production view does not need to be owned by a superuser.
What security invoker changes
security_invoker = true makes PostgreSQL check underlying-relation permissions using the role executing the query. The caller still needs permission on the view. [2]
The owner can change the existing view:
ALTER VIEW api.active_customers
SET (security_invoker = true);
The equivalent option belongs before AS when creating a new view:
CREATE VIEW api.active_customers
WITH (security_invoker = true)
AS
SELECT id, name, email, city
FROM private.customers
WHERE status = 'active';
Use the alteration for an existing object; the second statement illustrates the creation syntax. View options can be changed without replacing the defining query. [11]
Immediately after enabling the option, our client still has only the view grant. Its previous query is therefore expected to fail with permission denied for table customers. The city filter is valid. The missing authorization is on the underlying table.
The owner can grant the columns needed by this example:
GRANT SELECT (id, name, email, city, status)
ON private.customers
TO support_reader;
The grant includes status because the view reads it in WHERE status = 'active', although it does not return it. The grant omits confidential_notes. Column privileges allow that distinction. [9, 12]
| Configuration | Whose base-table permissions are checked | Expected view query |
|---|---|---|
| Default view and view-only grant | app_owner | Succeeds |
| Invoker view and view-only grant | support_reader | Fails |
| Invoker view plus necessary base-column grants | support_reader | Succeeds |
In the successful cases, the example query still returns Alice. The view's active-only filter remains part of the query. The setting chooses the permission identity; it does not add or remove business conditions.
Schema access and direct table queries
An invoker view requires underlying-object privileges, but its caller does not need USAGE on the underlying schema merely to follow the stored view definition. Those object names were resolved when the view was created. The caller needs USAGE on the schema containing the view. [2]
Consequently, the previous column grant can make the invoker view work while a newly submitted direct reference to private.customers fails because support_reader lacks schema USAGE on private.
If the owner also grants:
GRANT USAGE ON SCHEMA private TO support_reader;
the client can submit a direct query using its permitted columns:
SELECT id, name, city, status
FROM private.customers;
With RLS still disabled, this query can return Bob's inactive row. The view's active-only filter is absent from this separate statement. The column grant still does not authorize confidential_notes.
Schema access and table privileges are distinct controls. Withholding schema USAGE limits name lookup, but revoking it is not a universal revocation of table access: previously resolved statements can remain relevant. Use object privileges and, where appropriate, RLS to enforce the actual data boundary. [9]
The invoker is a database role
An application's human user and its database role are separate identities. PostgreSQL reports the current execution identity through current_user; session_user normally identifies the original session user. Explicit role changes can make them differ. [15]
SELECT current_user, session_user;
If every request in a backend pool runs as app_backend, an invoker view checks that role for every request. It does not automatically discover the human signed into the web application.
A per-customer authorization design must carry identity into the database deliberately. That can involve controlled role changes or an RLS design using trusted request context. A caller-editable tenant value is not sufficient proof of identity. Keep the mapping in an application or database path the client cannot freely impersonate.
Owner-based relation checks also do not turn the whole session into the owner. Functions retain their own execution rules, and a view's invoker setting is distinct from a function's SECURITY DEFINER property. [2, 15]
Combining the view with row level security
RLS defines which table rows a role may access. It works alongside object privileges; a policy does not supply a missing SELECT grant. When RLS is enabled without an applicable policy, ordinary access is denied by default. [13]
Continue with the invoker view and the column grants already shown. Suppose support may read customers in Stockholm. For this example, this is the only applicable policy:
ALTER TABLE private.customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY support_stockholm_customers
ON private.customers
FOR SELECT
TO support_reader
USING (city = 'Stockholm');
With the invoker setting, the policies applicable to support_reader govern underlying access. A default view instead selects policies according to its owner. [14]
The client now queries the view without an extra city condition:
SELECT id, name, city
FROM api.active_customers;
The policy supplies the Stockholm restriction and the view supplies the active restriction.
| Customer | View condition passes | RLS condition passes | Returned |
|---|---|---|---|
| Alice | Yes | Yes | Yes |
| Bob | No | Yes | No |
| Clara | Yes | No | No |
A direct table query, if separately authorized, still follows RLS but does not inherit the view's active-only condition. It can therefore return inactive Stockholm customers. If active-only visibility must hold for every access path, place that rule in the appropriate underlying authorization policy as well.
Table owners normally bypass RLS. Superusers and roles with BYPASSRLS also bypass it; FORCE ROW LEVEL SECURITY can make an ordinary owner subject to policies. A table-owner view can therefore expose more rows than a caller expects. Multiple permissive policies are combined with OR, so adding another policy can also widen the allowed set. [13, 14]
Security barrier protects filter evaluation
security_barrier addresses a different issue from security_invoker. A normal optimizer can reorder expressions. A caller-supplied function or an error-producing expression could otherwise receive values from a row before the view's hiding condition rejects that row. [8]
When the view itself enforces a row visibility boundary for arbitrary SQL callers, configure:
ALTER VIEW api.active_customers
SET (security_barrier = true);
The barrier restricts unsafe movement of expressions across that boundary. PostgreSQL can still move suitable leakproof expressions when that is safe. The restriction can exclude faster plans or some index access paths. [8]
| Control | Question it answers |
|---|---|
| security_invoker | Whose underlying-relation privileges and policies apply? |
| security_barrier | Which caller expressions can be evaluated before protected row filters? |
| CHECK OPTION | Must rows written through an updatable view remain visible through its conditions? |
These controls are independent. A barrier is neither a query timeout nor an instruction to make a view read-only. It is also not a guarantee against every inference from timing or query plans. [8]
Writes through a view can change the source table
Updatability and permission are separate. For a simple automatically updatable view, PostgreSQL can translate INSERT, UPDATE, or DELETE into operations on its underlying relation. A client still needs the corresponding privilege on the view. [1, 9]
Consider:
UPDATE api.active_customers
SET city = 'Gothenburg'
WHERE id = 42;
With only SELECT, this is denied. With a suitable view update grant, a default view can allow a write through the owner's underlying authority. An invoker view also requires the caller's relevant underlying privileges. Reading columns used in the update condition requires appropriate SELECT authority too. RLS and constraints can impose additional restrictions. [2, 9, 14]
Automatic updatability has specific conditions:
- Exactly one entry appears in
FROM, and it is a table or another updatable view. - The top level has no
WITH,DISTINCT,GROUP BY,HAVING,LIMIT, orOFFSET. - The top level has no
UNION,INTERSECT, orEXCEPT. - The select list has no aggregate, window, or set-returning functions.
A computed column can coexist with writable direct-column references; the computed expression itself is read-only. More complex views can use INSTEAD OF triggers or rules to define their write behavior. [1, 2]
An unindexed condition on an authorized update can make locating target rows expensive. That is a performance concern within a write operation, not a reason to grant additional permissions.
Keep written rows inside the view when required
Without a check option, an authorized insert or update can produce a row that no longer satisfies a view's condition. A separate editable view makes the example clear without changing the read interface:
CREATE VIEW api.active_customer_editor
WITH (security_invoker = true)
AS
SELECT id, name, email, city, status
FROM private.customers
WHERE status = 'active'
WITH CASCADED CHECK OPTION;
For this invoker view, supply the necessary view and underlying-column privileges before permitting writes. An authorized attempt to change a visible row's status to inactive through it is rejected. LOCAL checks the current view's condition; CASCADED includes underlying view conditions. Check options have restrictions around trigger- or rule-managed writes, so inspect the actual write path before relying on them. [2]
Plain reads still take locks and use snapshots
A normal SELECT acquires ACCESS SHARE locks on referenced tables. These are compatible with ordinary insert, update, and delete locks, but conflict with ACCESS EXCLUSIVE, used by operations such as DROP TABLE, TRUNCATE, and many schema changes. Locks normally remain until the transaction ends. [16]
A read can therefore coexist with ordinary writes yet delay a migration. Expensive queries can also slow writers through resource competition without waiting on the same row locks.
SELECT id, name
FROM api.active_customers
WHERE id = 42
FOR UPDATE;
This is a locking read. Where supported, applying a locking clause to a view affects contributing underlying rows. PostgreSQL requires UPDATE privilege as well as SELECT for these locking clauses. A role restricted to SELECT on the view does not thereby gain row-locking authority. [12, 16]
A standard view also follows transaction visibility. At the default READ COMMITTED level, a normal query sees other transactions' changes committed before that statement began, plus earlier writes in its own transaction. It does not continually absorb new commits during execution. REPEATABLE READ retains a transaction snapshot across statements. [7]
Consequently, "no refresh required" is accurate for an ordinary view, while "always the newest data at every instant" is not. Base-table writes require no maintenance of a stored ordinary-view result, because none exists. Long-lived snapshots can delay removal of old row versions, and idle transactions can retain locks or contribute to table bloat. [7, 20, 29]
Generate queries with explicit limits on flexibility
PostgreSQL applies its normal authorization rules whether SQL was handwritten, assembled by an application, or produced by an ORM. A generated query does not receive extra privileges because it names a view.
For values, use driver parameters. For example, the application's SQL template can be:
SELECT id, name, email
FROM api.active_customers
WHERE city = $1
ORDER BY id
LIMIT $2;
The application supplies the values separately through its database driver. Parameter placeholders represent values, not identifiers or arbitrary SQL fragments. If callers choose columns, sort directions, or operators, map those choices to approved SQL components. Bind values and quote identifiers correctly where construction is necessary. [18]
Use a deterministic ordering for pagination, including a unique tie-breaker where needed. The example uses the unique id. Large offsets still require PostgreSQL to compute the skipped rows, so a small page size does not make a deep offset inexpensive. [33]
A parameterized query is not automatically cheap. Prepared statements can use custom or generic plans, and different parameter values can have very different selectivity. Investigate the plan used by the application when one search is fast and another is unexpectedly slow. [19]
For a customer-search API, I would define which searches the product supports, their maximum result size, and when expensive operations need a separate reporting path. A view alone cannot enforce a general rule such as "every query must use an index."
Inspect the actual plan
Start with planning information:
EXPLAIN (VERBOSE)
SELECT id, name, email
FROM api.active_customers
WHERE city = 'Stockholm';
Then, in an environment where running the query is appropriate, measure it:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, email
FROM api.active_customers
WHERE city = 'Stockholm';
ANALYZE executes the statement. Plain EXPLAIN shows the proposed plan without its normal execution phase. Do not run EXPLAIN ANALYZE on a write statement expecting it to be a harmless preview. [6]
| Plan detail | What to examine |
|---|---|
| Seq Scan or Parallel Seq Scan | How much of the base relation is examined? |
| Index Cond | Which conditions help locate candidates through an index? |
| Filter and Rows Removed by Filter | How much work remains after locating candidates? |
| Actual rows and loops | How many times does a plan step execute? |
| Buffers and temporary activity | Are pages found in cache, read, or spilled during processing? |
Compare estimates with actual rows, and test representative data distributions. Use the application's effective role, relevant settings, and query shape. A plan observed as a privileged owner may not represent an invoker view protected by the client's RLS policies. The tiny example in this article establishes semantics; use realistic data to assess performance. [5, 6]
Materialized views and reporting replicas
A materialized view stores query results. Reads normally use those stored rows, and the materialized view can have its own indexes. Changes to source tables do not automatically refresh it, and direct INSERT, UPDATE, and DELETE are not supported. This can reduce repeated joins or aggregations when some data delay is acceptable. [22]
CREATE MATERIALIZED VIEW api.active_customer_snapshot AS
SELECT id, name, email, city
FROM private.customers
WHERE status = 'active';
CREATE UNIQUE INDEX active_customer_snapshot_id_idx
ON api.active_customer_snapshot (id);
REFRESH MATERIALIZED VIEW CONCURRENTLY
api.active_customer_snapshot;
The concurrent refresh example depends on an already populated materialized view and a qualifying unique index using column names and covering all rows. Concurrent refresh is not automatic incremental maintenance. It reruns the defining query and refreshes the stored contents. Only one refresh can run on a given materialized view at a time. [23]
A materialized view on the primary server still shares that server's resources, and refresh work still reads the sources. It also exposes stored data according to its own access configuration; do not assume that reading it re-evaluates the original caller's base-table RLS policy. [22]
For substantial reporting workloads, a separate reporting database or read replica can move query execution away from the primary. A physical standby introduces tradeoffs: replication lag, recovery conflicts, and configuration choices affecting long queries. Offloading needs operational design as well as a new connection string. [24]
| Approach | Data source for reads | Main tradeoff |
|---|---|---|
| Standard view | Underlying relations under query visibility rules | Flexible live queries still consume source-server resources. |
| Materialized view | Stored results from the last completed refresh | Refresh work and delayed data must be acceptable. |
| Reporting replica or database | A separate maintained copy | Replication or ingestion behavior and lag must be managed. |
Choose the access model before exposing the query interface
For a support interface whose requirement is "read only these approved columns and rows, with no base-table grants," I would keep the ordinary owner-based view model, grant SELECT narrowly, and use a security barrier when the view's row filter protects against arbitrary caller expressions. The owner should have only the underlying authority needed for that interface.
For a view intended to respect each database caller's existing table authorization, I would use an invoker view and verify the necessary column grants and RLS policies. If one backend role serves all application users, the application-to-database identity mapping must be designed explicitly.
For either design, I would review the supported filters and their plans, maintain useful statistics and indexes, cap concurrent work, and monitor slow query patterns. pg_stat_statements can help identify expensive or frequently executed statement patterns when it is configured. [27]
Set time limits appropriate to the workload. statement_timeout limits statement duration, lock_timeout limits time spent waiting for locks, and idle-transaction timeouts reduce abandoned-session problems. A role's default settings are operational defaults, not necessarily tamper-proof restrictions for clients allowed to issue arbitrary SQL and change session settings. Enforce stronger limits through the controlled application or service boundary where needed. [20, 30]
Validate the real client role before rollout. Test a permitted lookup, an unindexed lookup, an omitted-column reference, direct table access, an attempted write, and the expected RLS behavior. Inspect the expensive query's plan with realistic data. Those checks establish both what the interface permits and what its permitted queries can cost.
SQL lab: statically reviewed; not executed against PostgreSQL in the authoring environment. The file includes setup requirements and assertions to check when you run it.
References
- PostgreSQL 18 Views and the Rule System. https://www.postgresql.org/docs/18/rules-views.html
- PostgreSQL 18 CREATE VIEW. https://www.postgresql.org/docs/18/sql-createview.html
- PostgreSQL 18 Introduction to Indexes. https://www.postgresql.org/docs/18/indexes-intro.html
- PostgreSQL 18 CREATE INDEX. https://www.postgresql.org/docs/18/sql-createindex.html
- PostgreSQL 18 Using EXPLAIN. https://www.postgresql.org/docs/18/using-explain.html
- PostgreSQL 18 EXPLAIN. https://www.postgresql.org/docs/18/sql-explain.html
- PostgreSQL 18 Transaction Isolation. https://www.postgresql.org/docs/18/transaction-iso.html
- PostgreSQL 18 Rules and Privileges. https://www.postgresql.org/docs/18/rules-privileges.html
- PostgreSQL 18 Privileges. https://www.postgresql.org/docs/18/ddl-priv.html
- PostgreSQL 18 GRANT. https://www.postgresql.org/docs/18/sql-grant.html
- PostgreSQL 18 ALTER VIEW. https://www.postgresql.org/docs/18/sql-alterview.html
- PostgreSQL 18 SELECT. https://www.postgresql.org/docs/18/sql-select.html
- PostgreSQL 18 Row Security Policies. https://www.postgresql.org/docs/18/ddl-rowsecurity.html
- PostgreSQL 18 CREATE POLICY. https://www.postgresql.org/docs/18/sql-createpolicy.html
- PostgreSQL 18 System Information Functions. https://www.postgresql.org/docs/18/functions-info.html
- PostgreSQL 18 Explicit Locking. https://www.postgresql.org/docs/18/explicit-locking.html
- PostgreSQL 18 Function Volatility Categories. https://www.postgresql.org/docs/18/xfunc-volatility.html
- PostgreSQL 18 PLpgSQL Basic Statements and Dynamic Commands. https://www.postgresql.org/docs/18/plpgsql-statements.html
- PostgreSQL 18 PREPARE. https://www.postgresql.org/docs/18/sql-prepare.html
- PostgreSQL 18 Client Connection Defaults. https://www.postgresql.org/docs/18/runtime-config-client.html
- PostgreSQL 18 Resource Consumption. https://www.postgresql.org/docs/18/runtime-config-resource.html
- PostgreSQL 18 Materialized Views. https://www.postgresql.org/docs/18/rules-materializedviews.html
- PostgreSQL 18 REFRESH MATERIALIZED VIEW. https://www.postgresql.org/docs/18/sql-refreshmaterializedview.html
- PostgreSQL 18 Hot Standby. https://www.postgresql.org/docs/18/hot-standby.html
- PostgreSQL 18 Partial Indexes. https://www.postgresql.org/docs/18/indexes-partial.html
- PostgreSQL 18 Indexes on Expressions. https://www.postgresql.org/docs/18/indexes-expressional.html
- PostgreSQL 18 pg_stat_statements. https://www.postgresql.org/docs/18/pgstatstatements.html
- PostgreSQL 15 Released. https://www.postgresql.org/about/news/postgresql-15-released-2526/
- PostgreSQL 18 Routine Vacuuming. https://www.postgresql.org/docs/18/routine-vacuuming.html
- PostgreSQL 18 ALTER ROLE. https://www.postgresql.org/docs/18/sql-alterrole.html
- PostgreSQL 18 The Cumulative Statistics System. https://www.postgresql.org/docs/18/monitoring-stats.html
- PostgreSQL 18 Index Only Scans and Covering Indexes. https://www.postgresql.org/docs/18/indexes-index-only-scans.html
- PostgreSQL 18 LIMIT and OFFSET. https://www.postgresql.org/docs/18/queries-limit.html