Skip to content

Basic Server-Side Application Development

SQL and PL/pgSQL functions, procedures, triggers, dynamic commands and server-side logic.

This course keeps the official topic order and exposes the English handout as readable, native Markdown for review and future editorial work.

Source status: 官方 PDF 已提取为原生 Markdown

Translation baseline: English baseline. The current English archive is the working baseline; the Russian edition is retained for chapter-level cross-checking.

Course scope

SQL and PL/pgSQL functions, procedures, triggers, dynamic commands and server-side logic.

Source status

LanguageCurrent packageVersionTopicsCourse page
EnglishDEV1-handouts-16-en.zip1621HTTP 200
РусскийDEV1-handouts-16.zip1621HTTP 200

Contents

Supplementary materials

  • catalogs.pdf
  • datatypes.pdf
  • dev1_new_features.pdf
  • dev1_student_guide.pdf
  • unix_commands.pdf

Editorial status

The available English handout is extracted from the official PDF. Where no current English archive was found, the route remains ready for a later source edition.

00 Lesson 00: PostgreSQL Backend Development / Basic Course

Native Markdown source page: PostgreSQL Backend Development / Basic Course
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_00_introduction.html Archive: DEV1-handouts-16-en.zip PDF pages: 10

Page 1: PostgreSQL Backend Development / Basic Course

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery at Bhrikuti summit, Nepal)

Use of course materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

In no event shall Postgres Professional company be liable for any damages or loss, including loss of profits, that arise from direct or indirect, special or incidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Developers

Source slide page 2: Developers{#source-figure-002 caption=“Source slide page 2: Developers”}

Speaker notes

For backend developers, we offer a special range of courses. Prior knowledge of the basics of the SQL language is required to complete these courses. There is no course on the SQL language in our line of courses, but there are many books and other educational resources with which you can master SQL. DEV1 is a basic course for developers. The DEV2 course examines the features of the server’s internal structure that affect the development of application code, and comprehensively discusses extensibility: the ability to extend server mechanisms with your own code, which allows you to use PostgreSQL to solve a wide variety of tasks. The QPT course examines in detail the mechanisms for planning and executing queries, discusses the configuration parameters related to performance, and examines the options for finding problematic queries and optimizing them. The PGPRO course discusses additional features provided by the Postgres Pro Enterprise DBMS. https://postgrespro.com/community/courses

Page 3: Administrators

Source slide page 3: Administrators{#source-figure-003 caption=“Source slide page 3: Administrators”}

  • DBA2, 4 days

Speaker notes

For administrators, we offer the following courses. The DBA1 basic course provides general information about the PostgreSQL architecture, installation process, basic server configuration and management. It also considers basic administration tasks and access control issues. Lastly, it provides an overview of backup and replication mechanisms.

The DBA2 course focuses on determining appropriate values of configuration parameters based on an understanding of the internals of the server. It also talks about monitoring the server and using feedback for iterative parameter tweaking. Besides, it touches upon localization settings, extension management, and server upgrades.

The DBA3 covers backups, as well as configuration and usage scenarios of physical and logical replication. It also provides a general overview of how to build scalable high-availability clusters and discusses the related challenges. The QPT and PGPRO courses are intended for both developers and administrators. DBA courses can be interesting to developers if they would like to deepen their knowledge of PostgreSQL internals, or if there is no separate administrator role in their project.

Page 4: About the DEV1 Course

Duration: 4 days

  • Background knowledge required
    • SQL fundamentals
    • experience with any procedural programming language
    • basic knowledge of Unix OS
  • Knowledge and skills gained
    • general information about the PostgreSQL architecture
    • working with the main database objects: tables, indexes, views
    • programming in SQL and PL/pgSQL on the server side
    • using the main data types, including records and arrays
    • setting up client-server interface

Speaker notes

This basic course introduces backend application developers to PostgreSQL fundamentals and writing stored procedures and functions in SQL and PL/pgSQL.

Page 5: Provided Resources

Pre-configured virtual machine

  • Xubuntu 24.04 OS
  • PostgreSQL 16 with documentation in English
  • a sample web application Bookstore
  • DBeaver
  • Course handouts
    • Student’s Guide
    • presentations, demonstrations, practical assignments and assignment keys
    • (in html and pdf)
    • reference materials: PostgreSQL functions and data types,
    • a chart of the main system catalog tables with psql commands,
    • some Unix commands

Speaker notes

If you are taking the course on your own, start with the Student’s Guide. Among other things, it shows where to download and how to use the virtual machine and other materials. All course materials are available at: https://postgrespro.com/community/courses/DEV1 Completing practical assignments is very important to gain the basic skills in working with PostgreSQL. Be sure to try to complete the assignments yourself first, and then review the provided keys, even if the assignment did not raise questions. Assignment keys may contain additional information that is not mentioned in presentations and demonstrations. Course handouts (presentations, demonstrations, practical assignments and keys) are available in two formats. HTML is good for browsing and copying code snippets. PDF is divided into pages and is easy to print. Additional reference materials will help you quickly find the information you need. The virtual machine comes with DBeaver pre-installed. Although we use psql throughout the course, you can use this GUI tool if you like.

Page 6: Outline and Schedule

Each training day is approximately 6 hours + lunch (1 hour)

  • Each topic usually consists of
    • a presentation with demos: ~25-60 min
    • practical assignments: ~20-30 min, including a break

Page 7: Day 1

Basic Tools

    1. Installation and Management, psql
  • Architecture
      1. PostgreSQL Fundamentals
      1. Isolation and MVCC
      1. Buffer Cache and WAL
  • Data Organization
      1. Logical Structure
      1. Physical Structure

Speaker notes

In the first day, we mainly cover theoretical questions. We present PostgreSQL architecture fundamentals, which is indispensable for efficient use of database features. The gained knowledge will be applied to practice in the upcoming days.

Page 8: Day 2

Source slide page 8: Day 2{#source-figure-008 caption=“Source slide page 8: Day 2”}

The Bookstore App

    1. Data Schema and API
  • SQL
      1. Functions
      1. Procedures
      1. Composite Types
  • PL/pgSQL
      1. Overview and Programming Structures

Speaker notes

Starting from the Bookstore app module on the second day, each topic contains two sets of practical assignments: the main ones related to the application itself (marked with a book icon) as well as supplemental assignments. It is impossible to complete all the practice within the allotted time (about 30 minutes). The supplemental assignments should be used for self-study.

Page 9: Day 3

PL/pgSQL (continued)

    1. Query Execution
    1. Cursors
    1. Dynamic Commands
    1. Arrays
    1. Error Handling

Page 10: Day 4

PL/pgSQL (continued)

    1. Triggers
    1. Debugging
  • Access Control
      1. Overview
  • Backup
      1. Logical Backup

01 Lesson 01: Basic Tools / Installation and Management, psql

Native Markdown source page: Basic Tools / Installation and Management, psql
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_01_tools_overview.html Archive: DEV1-handouts-16-en.zip PDF pages: 20

Page 1: Basic Tools / Installation and Management, psql

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • PostgreSQL Installation Types
  • Server Management
  • Server Message Log
  • Configuration Parameters
  • Using psql

Page 3: Database Cluster

Source slide page 3: Database Cluster{#source-figure-003 caption=“Source slide page 3: Database Cluster”}

  • client

Speaker notes

Let’s start with the general concepts.

PostgreSQL is a program that belongs to the class of database management systems.

When this program is running, we call it a PostgreSQL server or a server instance. So far, the server seems to be a “black box” for us, but gradually we will get acquainted with how it works.

The data managed by PostgreSQL is stored in databases. One instance of PostgreSQL simultaneously manages several databases. This set of databases is called a database cluster. We will talk more about databases in the “Data organization. Logical structure” lesson.

The server interacts with clients. Clients are external applications that can connect to server databases and send queries for execution.

To summarize: a database cluster is the data in files; a server or a server instance is a program that manages the database cluster; a client is an application that “talks” to the server.

Page 4: Installation

Installation types

  • pre-build packages (preferred)
  • installation from source code
  • without installation (cloud services)
  • Extensions
    • provide additional features
    • installed separately
    • shipped with the server as modules and programs (~50 extensions)

Speaker notes

The preferred option for installing PostgreSQL is via a package manager (such as apt or rpm) using pre-built packages. This gives you a comprehensive installation that is easy to support and upgrade. There are packages available for most operating systems.

Another option is to build PostgreSQL from source code. This may be necessary if you want to set up a non-standard configuration or deploy on an exotic platform.

Pre-built packages and source codes: http://www.postgresql.org/download/

Besides, you can work with cloud-based managed databases that do not require any installation at all. Such ready-to-use services are provided by all major cloud platforms (Amazon RDS, Google Cloud SQL, Microsoft Azure).

In this course, we are going to use a virtual machine with Xubuntu 24 OS; PostgreSQL 16 is installed from the package for this OS. In addition, the installation is set up to start and stop PostgreSQL when the OS starts and stops.

There are a lot of PostgreSQL extensions that add new database functionality “on the fly”, without modifying the system core. About 50 extensions are included into the PostgreSQL distribution itself.

https://postgrespro.com/docs/postgresql/16/contrib

https://postgrespro.com/docs/postgresql/16/contrib-prog

The list of available extensions and their installation status can be accessed using the pg_available_extensions view.

Page 5: Server Management

Management tools

  • pg_ctlcluster
  • pg_ctl
  • Main operations
    • start the server
    • stop the server
    • reload configuration parameters

Speaker notes

The main server management tasks are: initializing the database cluster, starting and stopping the server, reloading configuration parameters, and a few others. To perform these actions, use the pg_ctl utility, which comes together with PostgreSQL.

In the Ubuntu PostgreSQL package, pg_ctl is not run directly, but through a special wrapper pg_ctlcluster. The help information for pg_ctlcluster can be viewed using man:

$ man pg_ctlcluster

Information about existing clusters and their status can be obtained using the following commands:

$ pg_lsclusters

$ pg_ctlcluster status

For more details about server management for database administrators, see:

https://postgrespro.com/docs/postgresql/16/app-pg-ctl

https://postgrespro.com/docs/postgresql/16/runtime

Page 6: Installation and Management

PostgreSQL in the course VM is installed from packages. PostgreSQL installation directory:

student$ ls -l /usr/lib/postgresql/16
total 8
drwxr-xr-x 2 root root 4096 Nov 27 12:16 bin
drwxr-xr-x 4 root root 4096 Nov 27 13:14 lib

The owner of the server software is the root user. The database cluster is automatically initialized when installed from a package and is located in the /var/lib/postgresql/16/main directory. This directory will be referred to as PGDATA in subsequent topics. The owner of the directory is postgres. Here is what the directory contains:

student$ sudo ls -l /var/lib/postgresql/16/main
total 96
drwx------ 1 postgres postgres 4096 Nov 27 14:17 base
drwx------ 1 postgres postgres 4096 Nov 27 14:17 global
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_commit_ts
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_dynshmem
drwx------ 4 postgres postgres 4096 Nov 27 13:16 pg_logical
drwx------ 4 postgres postgres 4096 Nov 27 13:14 pg_multixact
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_notify
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_replslot
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_serial
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_snapshots
drwx------ 1 postgres postgres 4096 Nov 27 14:17 pg_stat
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_stat_tmp
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_subtrans
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_tblspc
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_twophase
-rw------- 1 postgres postgres    3 Nov 27 13:14 PG_VERSION
drwx------ 1 postgres postgres 4096 Nov 27 13:14 pg_wal
drwx------ 2 postgres postgres 4096 Nov 27 13:14 pg_xact
-rw------- 1 postgres postgres   88 Nov 27 13:14 postgresql.auto.conf
-rw------- 1 postgres postgres  130 Nov 27 14:17 postmaster.opts
-rw------- 1 postgres postgres  108 Nov 27 14:17 postmaster.pid

With the package installation, PostgreSQL is automatically added to the OS startup list, so there is no need to start it manually. Use the following commands to control the server as a privileged OS user with sudo: Stop the server:

student$ sudo pg_ctlcluster 16 main stop

Start the server:

student$ sudo pg_ctlcluster 16 main start

Restart:

student$ sudo pg_ctlcluster 16 main restart

Reload configuration:

student$ sudo pg_ctlcluster 16 main reload

Get server information:

student$ sudo pg_ctlcluster 16 main status
pg_ctl: server is running (PID: 2836)
/usr/lib/postgresql/16/bin/postgres "-D" "/var/lib/postgresql/16/main" "-c"
"config_file=/etc/postgresql/16/main/postgresql.conf"

List of installed instances (works without sudo):

student$ pg_lsclusters
Ver Cluster Port Status Owner    Data directory              Log file
16  main    5432 online postgres /var/lib/postgresql/16/main

Page 7: Page 7

/var/log/postgresql/postgresql-16-main.log

Page 8: Server Message Log

Server message log contains

  • server messages
  • user session messages
  • application messages
  • Log configuration
    • log file location
    • message format
    • events to log

Speaker notes

Database operations are tracked in the server message log. The log keeps records about starting and stopping the server, as well as various signal messages, including messages about possible issues.

It can also store records of executed commands, their running time, locks that occur, etc. It can be used to trace user sessions.

Application developers can generate and log their own messages.

PostgreSQL allows you to flexibly configure which messages and in which format should be recorded into the log.

For example, the CSV and JSON output formats are convenient for automating log analysis.

https://postgrespro.com/docs/postgresql/16/runtime-config-logging

Page 9: Server Message Log

The server message log is located here:

student$ ls -l /var/log/postgresql/postgresql-16-main.log
-rw-r----- 1 postgres adm 4532 Nov 27 14:17 /var/log/postgresql/postgresql-16-main.log

Let’s look at the last messages:

student$ tail -n 10 /var/log/postgresql/postgresql-16-main.log
2025-11-27 14:17:36.340 MSK [812] LOG:  background worker "logical replication launcher"
(PID 837) exited with exit code 1
2025-11-27 14:17:36.344 MSK [829] LOG:  shutting down
2025-11-27 14:17:36.350 MSK [829] LOG:  checkpoint starting: shutdown immediate
2025-11-27 14:17:36.382 MSK [829] LOG:  checkpoint complete: wrote 3 buffers (0.0%); 0
WAL file(s) added, 0 removed, 0 recycled; write=0.009 s, sync=0.005 s, total=0.038 s;
sync files=2, longest=0.003 s, average=0.003 s; distance=0 kB, estimate=0 kB;
lsn=0/1963610, redo lsn=0/1963610
2025-11-27 14:17:36.391 MSK [812] LOG:  database system is shut down
2025-11-27 14:17:37.181 MSK [2836] LOG:  starting PostgreSQL 16.11 (Ubuntu
16.11-1.pgdg24.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu
13.3.0-6ubuntu2~24.04) 13.3.0, 64-bit
2025-11-27 14:17:37.182 MSK [2836] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2025-11-27 14:17:37.186 MSK [2836] LOG:  listening on Unix socket
"/var/run/postgresql/.s.PGSQL.5432"
2025-11-27 14:17:37.207 MSK [2839] LOG:  database system was shut down at 2025-11-27
13:16:17 MSK
2025-11-27 14:17:37.243 MSK [2836] LOG:  database system is ready to accept connections

Page 10: Configuration Parameters

For the entire instance

  • the main configuration file is postgresql.conf
ALTER SYSTEM modifies postgresql.auto.conf
  • For the current session
SET/RESET
set_config()
  • View the current setting
SHOW
current_setting()
pg_settings
\dconfig

Speaker notes

The PostgreSQL server configuration is governed by a variety of parameters that are used to manage resource consumption, tune system processes and user sessions, manage the server log, and handle many other tasks. It’s important to know how to check and update the parameter values.

Server configuration is usually stored in configuration files. The main configuration file is postgresql.conf, it has to be edited manually. The second configuration file is postgresql.auto.conf. Its contents are set using the ALTER SYSTEM command. The parameters set via ALTER SYSTEM take precedence over the parameters in postgresql.conf.

File and directory inclusion directives include and include_dir allow splitting large postgresql.conf into manageable parts. This may come in handy when managing multiple servers with similar configurations.

Most configuration parameters can be adjusted within a session with no server restart. You can define custom parameters and manage them in the same manner as with the system parameters.

Different ways of setting and updating parameters are described here:

https://postgrespro.com/docs/postgresql/16/config-setting

And the current parameter values are shown in the pg_settings view.

https://postgrespro.com/docs/postgresql/16/view-pg-settings

Page 11: Configuration Parameters

The main postgresql.conf configuration file is located in this directory:

student$ ls -l /etc/postgresql/16/main
total 60
drwxr-xr-x 2 postgres postgres  4096 Nov 27 13:14 conf.d
-rw-r--r-- 1 postgres postgres   315 Nov 27 13:14 environment
-rw-r--r-- 1 postgres postgres   143 Nov 27 13:14 pg_ctl.conf
-rw-r----- 1 postgres postgres  5743 Nov 27 13:14 pg_hba.conf
-rw-r----- 1 postgres postgres  2640 Nov 27 13:14 pg_ident.conf
-rw-r--r-- 1 postgres postgres 29960 Nov 27 13:14 postgresql.conf
-rw-r--r-- 1 postgres postgres   317 Nov 27 13:14 start.conf

Other configuration files are also stored here. Let’s check the value of the work_mem parameter:

=> SHOW work_mem;
 work_mem
----------
 4MB
(1 row)

The work_mem parameter specifies the amount of memory that will be used for internal sorting and hash table allocation operations before using temporary files on disk. 4MB is the default value and it is too small. Let’s say we want to increase it to 16MB for the entire instance. There are various ways to do this. First, you can uncomment and change the line in postgresql.conf where the parameter is defined:

student$ grep '#work_mem' /etc/postgresql/16/main/postgresql.conf
#work_mem = 4MB
# min 64kB

Secondly, you can place the parameter definition in a file with the .conf suffix in the /etc/postgresql/16/main/conf.d directory or in a custom configuration file, the location of which should be set in the include directive of the main configuration file postgresql.conf. Thirdly, you can change the parameter value using an SQL command, and this is what we will do:

=> ALTER SYSTEM SET work_mem TO '16MB';
ALTER SYSTEM

This change does not end up in postgresql.conf, but in postgresql.auto.conf, which is located in the PGDATA directory:

student$ sudo cat /var/lib/postgresql/16/main/postgresql.auto.conf
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
work_mem = '16MB'

Reload the configuration files for the change to take effect. To do this, you can use pg_ctlcluster, or use the SQL function:

=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Verify that the new value is applied. Here is another way to check it:

=> SELECT current_setting('work_mem');
 current_setting
-----------------
 16MB
(1 row)

To restore the default value of a parameter, you can use the RESET command instead of SET (and then reload the configuration, of course):

=> ALTER SYSTEM RESET work_mem;

Page 12: Page 12

ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Most parameter values can be set for the current session live. For example, you can increase work_mem just for a single query that you expect to sort a large volume of data:

=> SET work_mem = '64MB';
SET

The new value will remain for the current session or even for the current transaction (with SET LOCAL). Yet another way to check a current value is to query the pg_settings view:

=> SELECT name, setting, unit FROM pg_settings WHERE name = 'work_mem';
   name   | setting | unit
----------+---------+------
 work_mem | 65536   | kB
(1 row)

Lastly, there is the \dconfig command:

=> \dconfig work_mem
List of configuration parameters
 Parameter | Value
-----------+-------
 work_mem  | 64MB
(1 row)

Page 13: The psql Client

Terminal client for working with PostgreSQL

  • Comes with the DBMS
  • Used by administrators and developers for interactive work and script execution

Speaker notes

There are other third-party tools available, but they are not considered in the scope of the course.

In the course, we will use the psql terminal client:

  1. psql is the only client supplied with the DBMS.

  2. The knowledge of psql will be useful to both developers and DB administrators, regardless of which tool they choose to work with at the end of the day.

For interactivity, psql provides built-in support for readline and pager programs (such as less and pspg), as well as the option to use external editing tools. Using psql, you can interact with the operating system, browse through the system catalog, and write scripts to automate routine tasks.

https://postgrespro.com/docs/postgresql/16/app-psql

Page 14: Connection

When starting psql, you need to specify the connection parameters. Required parameters include: database name, same as the user name by default user name (role), same as the OS user name by default host, local connection by default port, usually 5432 by default This is how you put them in:

student$ psql -d database -U user -h host -p port

The VM is set up to connect to PostgreSQL without specifying the parameters:

student$ psql

Check the connection:

=> \conninfo
You are connected to database "student" as user "student" via socket in
"/var/run/postgresql" at port "5432".

The \connect command creates a new connection without leaving psql. You can use its short form: \c. From here on, the optional parts of commands will be shown in square brackets: \c[onnect].

Help and Documentation

You don’t need to open the documentation every time you need to check something. Instead, you can get the information from within the system directly. The command

student$ psql --help

displays the help information for psql. If PostgreSQL was installed with documentation, then you can get the reference manual with the command

student$ man psql

psql can execute SQL commands and its own commands which start with a backslash, such as \conninfo. psql commands are always single-line, unlike SQL commands. Inside psql, you can get a list and a brief description of all psql commands: \? displays the list of psql commands \h[elp] provides a list of SQL commands that the server supports, as well as the syntax of an SQL command (if specified).

Formatting the Output

The psql client can return output in different formats: aligned format unaligned format extended format The aligned format is the default:

=> SELECT name, setting, unit FROM pg_settings LIMIT 7;
            name            |  setting   | unit
----------------------------+------------+------
 allow_in_place_tablespaces | off        |
 allow_system_table_mods    | off        |
 application_name           | psql       |
 archive_cleanup_command    |            |
 archive_command            | (disabled) |
 archive_library            |            |
 archive_mode               | off        |
(7 rows)

It sets each column’s width based on its contents. There’s also the header and the footer. psql commands to switch display modes: \a — mode switch: aligned/unaligned. \t — toggle the header and footer display. Let’s disable alignment, heading, and summary line:

=> \a \t
Output format is unaligned.
Tuples only is on.
=> SELECT name, setting, unit FROM pg_settings LIMIT 7;

Page 15: Page 15

allow_in_place_tablespaces|off|
allow_system_table_mods|off|
application_name|psql|
archive_cleanup_command||
archive_command|(disabled)|
archive_library||
archive_mode|off|
=> \a \t
Output format is aligned.
Tuples only is off.

This format is not the best for readability, but can be more suitable for automatic processing. The extended format is best for displaying multiple columns for one or several records. It is switched on for the current command with the \gx flag at the end in place of a semicolon:

=> SELECT name, setting, unit, category, context, vartype,
    min_val, max_val, boot_val, reset_val
FROM pg_settings
WHERE name = 'work_mem' \gx
-[ RECORD 1 ]----------------------
name      | work_mem
setting   | 4096
unit      | kB
category  | Resource Usage / Memory
context   | user
vartype   | integer
min_val   | 64
max_val   | 2147483647
boot_val  | 4096
reset_val | 4096

Or you can toggle it on and off with the \x flag. The \pset command displays all formatting options.

Interacting with the OS and Executing Scripts

psql can run shell commands:

=> \! pwd
/home/student

An SQL query can generate several other SQL queries and write them to a file using the \o[ut] command:

=> \a \t \pset fieldsep ''
Output format is unaligned.
Tuples only is on.
Field separator is "".
=> \o dev1_tools.sql
=> SELECT format('SELECT %L AS tbl, count(*) FROM %I;', tablename, tablename)
FROM pg_tables LIMIT 3;

Nothing is printed to the screen (to the standard output). Check the file:

=> \! cat dev1_tools.sql
SELECT 'pg_statistic' AS tbl, count(*) FROM pg_statistic;
SELECT 'pg_type' AS tbl, count(*) FROM pg_type;
SELECT 'pg_foreign_table' AS tbl, count(*) FROM pg_foreign_table;

Revert to default output and formatting.

=> \o \t \a
Tuples only is off.
Output format is aligned.

We can run the commands from the file using \i[nclude]:

=> \i dev1_tools.sql
     tbl      | count
--------------+-------
 pg_statistic |   409
(1 row)
   tbl   | count
---------+-------
 pg_type |   613
(1 row)
       tbl        | count
------------------+-------
 pg_foreign_table |     0
(1 row)

There are other ways to execute commands, including from files. After executing the commands, the psql session will be terminated: psql < filename psql -f < filename psql -c ‘command’ (one command per call)

Page 16: psql Variables

Like shell, psql has its own variables. Set a variable:

=> \set TEST Hi!

To replace a variable with its value, use the colon in front of its name:

=> \echo :TEST
Hi!

Unset a value:

=> \unset TEST
=> \echo :TEST
:TEST

Variables can be used, for example, to store the text of frequently used queries. Here is a query that returns a list of the five largest tables:

=> \set top5 'SELECT tablename, pg_total_relation_size(schemaname||''.''||tablename) AS bytes FROM pg_tables ORDER BY bytes DESC LIMIT 5;'

Now we can execute the query by just typing:

=> :top5
   tablename    |  bytes
----------------+---------
 pg_proc        | 1245184
 pg_rewrite     |  745472
 pg_attribute   |  720896
 pg_description |  630784
 pg_statistic   |  294912
(5 rows)

You can add the command to set the value of top5 to the .psqlrc starter file in the user’s home directory. Commands from .psqlrc are executed automatically every time psql starts. If used with no parameters, \set displays all currently set variables and their values, including the built-in ones. You can check the help for the built-in variables:

\? variables

To exit psql, either use \q[uit], quit, or exit, or press Ctrl+D:

=> \q

Page 17: Takeaways

Installing PostgreSQL from pre-built packages is the preferred installation type

  • Pre-packaged distributions bring some OS specifics that you should be aware of:
    • how to start and stop the server
    • location of configuration files
    • location of the server message log
  • psql is a client application for working with PostgreSQL

Page 18: Practice

  1. Set the parameter work_mem to 8MB for all sessions. Reload the configuration and verify that the changes have taken effect. Restore the default value. 2. Put the command to create a table to the ddl.sql file. Put the commands to insert rows into this table to the populate.sql file. Run both scripts and verify that the table has been created and populated. 3. Find today’s records in the server message log.

Speaker notes

To launch psql in the terminal window, type psql without parameters.
student:~$ psql

It is convenient to have separate databases for tasks that are related to different topics:

student/student=# CREATE DATABASE tools_overview;
CREATE DATABASE
student/student=# \c tools_overview
You are now connected to database "tools_overview" as user
"student".
student/tools_overview=#
  1. Use the ALTER SYSTEM command.

  2. If the output does not fit into one terminal screen, psql forwards it to the tool called less for page-by-page output. To return to the prompt, press q.

Page 19: 1. Configuration Parameters

Connect to the database:

student$ psql

Set the parameters:

=> ALTER SYSTEM SET work_mem = '8MB';
ALTER SYSTEM

Reload the configuration:

=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Check the parameter value:

=> \dconfig work_mem
List of configuration parameters
 Parameter | Value
-----------+-------
 work_mem  | 8MB
(1 row)

Reset to the default value:

=> ALTER SYSTEM RESET work_mem;
ALTER SYSTEM

Apply changes:

=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

2. Executing Scripts in psql

Write a command into ’ddl.sql’ to create a table in PostgreSQL (you can use any text editor):

student$ cat > ~/ddl.sql <<EOF
CREATE TABLE keywords (
    word text,
    category text,
    description text
);
EOF

Write commands into ’populate.sql’ to populate the table:

student$ cat > ~/populate.sql <<EOF
INSERT INTO keywords
  SELECT word, catcode, catdesc FROM pg_get_keywords();
EOF

Create a new database and connect to it:

=> CREATE DATABASE tools_overview;
CREATE DATABASE
=> \c tools_overview
You are now connected to database "tools_overview" as user "student".

Run the scripts and check the records in the table:

Page 20: Page 20

=> \i ddl.sql
CREATE TABLE
=> \i populate.sql
INSERT 0 471
=> SELECT * FROM keywords LIMIT 10;
   word    | category | description
-----------+----------+-------------
 abort     | U        | unreserved
 absent    | U        | unreserved
 absolute  | U        | unreserved
 access    | U        | unreserved
 action    | U        | unreserved
 add       | U        | unreserved
 admin     | U        | unreserved
 after     | U        | unreserved
 aggregate | U        | unreserved
 all       | R        | reserved
(10 rows)

3. Viewing the Log

The log can be viewed in any text editor. Each log record starts with a date, contains the server process ID (with the packaged installation configuration), and may span several lines. The last records will be at the end of the file.

student$ tail /var/log/postgresql/postgresql-16-main.log
2025-11-27 14:26:39.316 MSK [44254] LOG:  database system is shut down
2025-11-27 14:26:40.321 MSK [46618] LOG:  starting PostgreSQL 16.11 (Ubuntu
16.11-1.pgdg24.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu
13.3.0-6ubuntu2~24.04) 13.3.0, 64-bit
2025-11-27 14:26:40.321 MSK [46618] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2025-11-27 14:26:40.325 MSK [46618] LOG:  listening on Unix socket
"/var/run/postgresql/.s.PGSQL.5432"
2025-11-27 14:26:40.341 MSK [46621] LOG:  database system was shut down at 2025-11-27
13:16:17 MSK
2025-11-27 14:26:40.367 MSK [46618] LOG:  database system is ready to accept connections
2025-11-27 14:26:43.184 MSK [46618] LOG:  received SIGHUP, reloading configuration files
2025-11-27 14:26:43.185 MSK [46618] LOG:  parameter "work_mem" changed to "8MB"
2025-11-27 14:26:43.572 MSK [46618] LOG:  received SIGHUP, reloading configuration files
2025-11-27 14:26:43.572 MSK [46618] LOG:  parameter "work_mem" removed from configuration
file, reset to default

The database can now be disconnected and deleted (see the Data Organization module for more)

=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE tools_overview;
DROP DATABASE

02 Lesson 02: Architecture / PostgreSQL Fundamentals

Native Markdown source page: Architecture / PostgreSQL Fundamentals
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_02_arch_general.html Archive: DEV1-handouts-16-en.zip PDF pages: 20

Page 1: Architecture / PostgreSQL Fundamentals

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo: Oleg Bartunov (Phu Monastery and Bhrikuti Peak, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Client-Server Protocol
  • Transactionality and its Implementation
  • Query Processing and Execution
  • Processes and Memory Structures
  • Storing Data on Disk and Data Processing
  • System Extensibility

Page 3: Client and Server

Source slide page 3: Client and Server{#source-figure-003 caption=“Source slide page 3: Client and Server”}

  • connection
  • authentication
  • query generation
  • query execution
  • transaction management
  • transactionality support

Speaker notes

A client application, such as psql or any other program written in any programming language, connects to the server and “communicates” with it. In order for the client and the server to understand each other, they must use the same communication protocol. Usually, the client uses a driver that implements the protocol and provides a set of functions to use in the program. Internally, the driver can use the standard protocol implementation (the libpq library), or can implement the protocol itself.

The language the client is written in is unimportant, as the functionality behind the syntax is defined by the protocol. As an example, we will use the SQL language and the psql client. Of course, no one really would program a client in SQL, but we will use it here purely for educational purposes. It should not be that difficult to substitute any of the SQL commands provided below with corresponding statements in your programming language of choice.

Generally speaking, a connection protocol allows the client to connect to a database in a cluster. The server performs authentication: decides if the client should be allowed to connect, i.e. by demanding a password.

Then, the client sends the server queries in the SQL language, the server executes the queries and sends back the results. A powerful and convenient query language is one of the fundamentals of relational databases.

Another one is the ability to maintain transaction support.

https://postgrespro.com/docs/postgresql/16/protocol

Page 4: Transactions

Source slide page 4: Transactions{#source-figure-004 caption=“Source slide page 4: Transactions”}

  • atomicity — everything or nothing consistency — integrity constraints and user restrictions isolation — parallel processes impact
  • durability — no data loss after a failure

Speaker notes

A transaction is a sequence of operations that preserves the consistency of data, provided that the operations are performed completely and without interference from other transactions.

Transactions must satisfy four properties collectively known as ACID:

● Atomicity. A transaction is either executed in full or not executed at all. To that end, the beginning of a transaction is marked with the BEGIN command, and the end with either COMMIT (commit changes) or ROLLBACK (undo changes).

● Consistency. Transactions move the database from one consistent state to another consistent one (consistency here means that certain restrictions are fulfilled).

● Isolation. Transactions running simultaneously should not affect the specific one.

● Durability. Once data is committed, it should not be lost even after a server failure.

In PostgreSQL, the client application is the side usually responsible for transaction management (that is, for determining what commands make up a transaction, and for committing or canceling the transaction). Transactions can be managed on the server side by stored procedures.

https://postgrespro.com/docs/postgresql/16/sql-begin

https://postgrespro.com/docs/postgresql/16/sql-savepoint

https://postgrespro.com/docs/postgresql/16/transactions

Page 5: Transaction Management

By default, psql runs in autocommit mode:

=> \echo :AUTOCOMMIT
on

This means that any single command sent without explicitly opening the transaction is committed immediately. Check if this mode is enabled by default in the PostgreSQL driver for your favourite programming language. Create a table with one row:

=> CREATE TABLE t(
    id integer,
    s text
);
CREATE TABLE
=> INSERT INTO t(id, s) VALUES (1, 'foo');
INSERT 0 1

Will another transaction see this table and row?

=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
(1 row)

Yes. Compare the result:

=> BEGIN; -- explicit start of transaction
BEGIN
=> INSERT INTO t(id, s) VALUES (2, 'bar');
INSERT 0 1

What will the other transaction see now?

=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
(1 row)

The changes are not yet committed so the other transaction does not see them.

=> COMMIT;
COMMIT

What about now?

=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
  2 | bar
(2 rows)

When autocommit is off, a transaction is implicitly opened upon command input. The command then must be committed manually.

=> \set AUTOCOMMIT off
=> INSERT INTO t(id, s) VALUES (3, 'baz');
INSERT 0 1

What do we see now?

Page 6: Page 6

=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
  2 | bar
(2 rows)

The changes are not there, as the transaction was opened implicitly.

=> COMMIT;
COMMIT

Now, finally:

=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
  2 | bar
  3 | baz
(3 rows)

Turn the default autocommit mode back on.

=> \set AUTOCOMMIT on

Individual changes can be rolled back without interrupting the entire transaction (although this is rarely used).

=> BEGIN;
BEGIN
=> SAVEPOINT sp;
SAVEPOINT
=> INSERT INTO t(id, s) VALUES (4, 'qux');
INSERT 0 1
=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
  2 | bar
  3 | baz
  4 | qux
(4 rows)
Note

Note Note how the transaction sees its own changes, even the uncommitted ones. Now, roll back to the save point. Rolling back to a save point does not imply a transfer of control (that is, it does not work as GOTO); only those changes to the database state that were made from the moment the point was set and up to the current moment are canceled.

=> ROLLBACK TO sp;
ROLLBACK

Check the table:

=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
  2 | bar
  3 | baz
(3 rows)

The changes have been rolled back, but the transaction is still open:

=> INSERT INTO t(id, s) VALUES (4, 'xyz');
INSERT 0 1

Page 7: Page 7

=> COMMIT;
COMMIT
=> SELECT * FROM t;
 id |  s
----+-----
  1 | foo
  2 | bar
  3 | baz
  4 | xyz
(4 rows)

Page 8: Query Processing

Source slide page 8: Query Processing{#source-figure-008 caption=“Source slide page 8: Query Processing”}

parsing ← system catalog rewriting ← rules planning ← statistics execution ← data

Speaker notes

Query execution is complicated. First, a query is sent from a client to the server as text. The server parses the text, analyzing its syntax (whether letters are formed into words, and words into commands) and semantics (whether there are tables and other objects in the database that the query refers to by name). To do that, the server needs data on what is actually stored in the database. This meta-data is called the system catalog and is stored in special tables in the same database.

A query can be rewritten (transformed). For example, a view name can be substituted with the query text. Users can implement their own transformations using the rule system.

SQL is a declarative language: a query defines what data to get, but not how to get it. It is at this point when the query (already parsed and presented in the form of a tree) is passed on to the planner, which develops an execution plan. For example, the planner can decide whether or not to use indexes to find the data for the query. To plan the execution efficiently, the planner needs certain information about the tables it is going to work with, such as the size of the tables and the distribution of data within them. Together, this information is called statistics.

When a plan is selected, the query is executed in accordance with it, and the result is returned to the client in its entirety.

https://postgrespro.com/docs/postgresql/16/query-path

This is convenient and simple when we are talking about just a row or two, provided by the simple mode of the protocol.

Page 9: Prepared Statements

Source slide page 9: Prepared Statements{#source-figure-009 caption=“Source slide page 9: Prepared Statements”}

parsing rewriting

  • binding ← parameter values planning execution

Speaker notes

Each query goes through the steps listed above: parsing, rewriting, planning and execution. But if the same query (possibly with different parameters) is executed over and over again, there is no point in parsing it anew every time.

Therefore, in addition to the usual query execution process, the PostgreSQL protocol provides an extended mode that can control statement execution more precisely.

One of its features is the ability to prepare a statement. When a statement is prepared, it is parsed and rewritten as usual and its parse tree is saved.

When the statement is executed, specific parameter values are bound to it. If necessary, planning is redone (in some cases, PostgreSQL remembers the query plan and does not repeat this step). Then, the statement is executed.

Another advantage of prepared statements is that they are protected from possible SQL injections.

https://postgrespro.com/docs/postgresql/16/sql-prepare

https://postgrespro.com/docs/postgresql/16/sql-execute

Page 10: Prepared Statements

In SQL, you can prepare a statement by using the PREPARE command (it is a PostgreSQL extension not present in the SQL standard):

=> PREPARE q(integer) AS
  SELECT * FROM t WHERE id = $1;
PREPARE

The statement is parsed and rewritten, and the parse tree is saved. A prepared statement can be called by its name using arbitrary parameters:

=> EXECUTE q(1);
 id |  s
----+-----
  1 | foo
(1 row)

For non-parametric statements, an execution plan is saved as well. For queries with parameters, as in the example, the parameter values are taken into account at the planning stage. The planner may decide that the plan built without considering the parameters is good enough. In this case, it will not try to plan the query again. How do you prepare and execute a statement using your favourite programming language? Can you execute a statement WITHOUT preparing it? All prepared statements in the current session can be displayed with:

=> SELECT * FROM pg_prepared_statements \gx
-[ RECORD 1 ]---+---------------------------------
name            | q
statement       | PREPARE q(integer) AS           +
                |   SELECT * FROM t WHERE id = $1;
prepare_time    | 2025-11-27 14:18:05.968349+03
parameter_types | {integer}
result_types    | {integer,text}
from_sql        | t
generic_plans   | 0
custom_plans    | 1

Page 11: Cursors

Source slide page 11: Cursors{#source-figure-011 caption=“Source slide page 11: Cursors”}

parsing rewriting

  • binding ← parameter values planning execution
  • output

Speaker notes

The client may not want to get all the output at once. There can be too much data, and not all of it may be needed.

This issue is solved by cursors, another feature of the extended mode. The protocol can open a cursor for any operator, and then receive the output row by row.

A cursor can be imagined as a sliding window that shows only a part of the output at a time. When an output row is received, the window shifts down. In other words, cursors allow you to work with relational data (that comes in sets) iteratively, row by row.

An open cursor is represented on the server by a so-called portal. This term is mentioned in the documentation, but in general, the words “cursor” and “portal” can be considered synonyms.

A statement used within a cursor is implicitly prepared (that is, its parsing tree and possibly execution plan are saved).

https://postgrespro.com/docs/postgresql/16/sql-declare

https://postgrespro.com/docs/postgresql/16/sql-fetch

Page 12: Cursors

When the SELECT command is executed, the server sends and the client receives all the rows at once:

=> SELECT * FROM t ORDER BY id;
 id |  s
----+-----
  1 | foo
  2 | bar
  3 | baz
  4 | xyz
(4 rows)

Cursors let the client to retrieve data in batches of rows.

=> BEGIN;
BEGIN
=> DECLARE c CURSOR FOR
  SELECT * FROM t ORDER BY id;
DECLARE CURSOR
=> FETCH c;
 id |  s
----+-----
  1 | foo
(1 row)

You can set the size of the batch:

=> FETCH 2 c;
 id |  s
----+-----
  2 | bar
  3 | baz
(2 rows)

The batch size is paramount for huge outputs, when processing them one row at a time is inefficient. What if we reach the end of the table?

=> FETCH 2 c;
 id |  s
----+-----
  4 | xyz
(1 row)
=> FETCH 2 c;
 id | s
----+---
(0 rows)

FETCH will just stop returning rows. All regular programming languages have a way to check for this condition. How do you get data row by row using the cursor in your favorite programming language? Is it possible NOT to use the cursor and get all the rows at once? How is the cursor batch size set? You can close your cursor when done, freeing up some resources:

=> CLOSE c;
CLOSE CURSOR

However, cursors are automatically closed when the transaction ends, so explicit closing is optional (except WITH HOLD cursors.)

=> COMMIT;

Page 13: Page 13

COMMIT

Page 14: Processes and Memory

Source slide page 14: Processes and Memory{#source-figure-014 caption=“Source slide page 14: Processes and Memory”}

Speaker notes

Between processing queries from clients, the server must store technical information, such as parsed queries and their plans and the status of open cursors (portals). Where is it stored and how?

Under the hood, a PostgreSQL server consists of several interacting processes. First of all, when the server starts, a process traditionally called postmaster is started. It starts all other processes (using the fork system call in Unix). It also “babysits” them: if any of the processes crashes, postmaster will restart it (or restart the entire server if it considers that the failed process could have damaged any of the shared data).

Operations of the server are maintained by a number of background processes. The main ones will be discussed in the following lessons.

In order for the processes to exchange information between them, postmaster allocates shared memory that all the processes can access. In addition to shared memory, each process has its own local memory, accessible only to itself.

Postmaster also listens for incoming connections. For each connecting client, postmaster generates a designated backend process for the client to communicate with on the server side, and each client gets its own process. The backend process performs authentication, among other things.

The space required to execute a client’s query (parsed queries and their plans, cursor states, system catalog cache, a place to sort data, etc.) is allocated in the local memory of the backend process of this client.

Page 15: Multiple Clients

Source slide page 15: Multiple Clients{#source-figure-015 caption=“Source slide page 15: Multiple Clients”}

Speaker notes

When multiple clients connect to a server, each one gets a backend process created for it. As long as there are not too many clients, RAM is sufficient, and connections do not occur too often, sustaining many connections at once is not a problem in itself.

However, when multiple processes try to access the same database object, things must be done to ensure that one process will not change the data while another is in the process of reading it.

For objects in shared memory, this is ensured by short-term locks. PostgreSQL does this carefully enough so that the system scales well with an increase in the number of processors (cores).

Tables are more complicated. Locks will have to be held until the end of transactions (that is, potentially for a long time), so scalability may suffer. To avoid that, PostgreSQL uses a multiversion concurrency control mechanism (MVCC) and snapshot isolation: multiple versions of the same data can exist simultaneously, and each process sees only its own (but always consistent) data snapshot. Now, only those processes that are trying to change data that has already been changed, but not yet committed by other processes, will be locked.

MVCC is the main mechanism that enables the first three properties of transactions (atomicity, consistency, and isolation). We will talk about it more in its dedicated lesson.

Page 16: Connection Pool

Source slide page 16: Connection Pool{#source-figure-016 caption=“Source slide page 16: Connection Pool”}

Speaker notes

If there are too many clients, or connections are established and broken too often, using a connection pool may help. This functionality is usually provided by the application server or third-party pool managers (the most popular of which is pgBouncer).

With a connection pool in place, clients connect not to the PostgreSQL server directly, but to the pool manager. The manager keeps several connections to the database server open and uses free ones to fulfill client queries. From the server’s point of view, the number of clients remains constant regardless of how many clients access the pool manager.

The drawback is that multiple clients end up sharing the same backend process, which, as we remember, stores client-specific state in its local memory (such as parsed queries for prepared statements). Therefore, care should be taken when developing applications for such deployments.

One of pgBouncer’s features is the ability to temporarily pause client connections without disconnecting them. This pause can be used for server software updates or other operations that require a server restart.

The use of connection pools is discussed in greater detail in the DEV2 course.

Page 17: Data Storage

Source slide page 17: Data Storage{#source-figure-017 caption=“Source slide page 17: Data Storage”}

Speaker notes

Data is stored as regular OS files on disks. How exactly the data is distributed among the files is discussed in one of following lessons.

Logically, the files are divided into pages (sometimes the term block is used). A page is usually 8KB in size. It can be changed within some limits (16 KB or 32 KB), but only during server compilation. A cluster that has been compiled and started up this way can work with pages of only one size.

Each page has a certain internal structure. It contains a header and actual data. There may be free space between them if the page is not fully occupied.

Since disks work much slower than RAM (especially HDD, but SSD too), data heading to disk is cached first. A buffer cache, a certain amount of space in RAM, is allocated for recently read pages. The idea is that the system may want to read the same pages multiple times, and keeping them on hand may save time compared to repeated disk scans. Any recently changed data is also cached for some time before being written on disk.

Page 18: Data Storage

Source slide page 18: Data Storage{#source-figure-018 caption=“Source slide page 18: Data Storage”}

Speaker notes

The PostgreSQL buffer cache is located in shared memory so that all processes have access to it.

PostgreSQL does not directly access disks storing its data. Instead, it relies on the operating system. The operating system also has its own data cache. Therefore, if a page is not found in the buffer cache, there is a chance that it is in the OS cache and access to the disk will be avoided.

In case of a failure (for example, power supply dies), the contents of the RAM are lost, and the data changed but not yet written to disk will be lost. This is unacceptable and breaks the durability property of transactions. To avoid that, PostgreSQL keeps a log that allows it to redo lost operations and restore data to a consistent state. We will talk about the buffer cache and the write-ahead log in the dedicated lesson later on.

Page 19: Extensibility

Source slide page 19: Extensibility{#source-figure-019 caption=“Source slide page 19: Extensibility”}

Speaker notes

PostgreSQL is designed with extensibility in mind. It allows to create new data types based on existing ones, write stored procedures for data processing, and offers a convenient toolkit for administration, monitoring, and performance tuning.

You can always write an extension that adds the necessary functionality that you need. Most extensions can be installed “hot”, without stopping the server. Thanks to this architecture, there are plenty of existing extensions doing things such as:

● adding support for programming languages (in addition to standard SQL, PL/pgSQL, PL/Perl, PL/Python and PL/Tcl);

● introducing new data types and operators to work with them;

● creating new index types that work more efficiently with specific data types (in addition to standard B-trees, hash indexes, GiST, SP-GiST, GIN, BRIN);

● starting background workers to perform additional tasks;

● connecting to external data sources;

● collecting system load information, performing monitoring, and generating reports;

● exploring system data structures.

Extensibility is discussed in more detail in the DBA2 and DEV2 courses.

Page 20: Takeaways

A server manages a database cluster

  • The protocol allows clients to connect to the server, transmit queries and manage transactions
  • Each client is served by a dedicated backend process
  • Data is stored in files and accessed via the operating system
  • Data is cached both in local memory (system catalog, parsed queries) and in shared memory (buffer cache)

03 Lesson 03: Architecture / Isolation and MVCC

Native Markdown source page: Architecture / Isolation and MVCC
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_03_arch_mvcc_overview.html Archive: DEV1-handouts-16-en.zip PDF pages: 18

Page 1: Architecture / Isolation and MVCC

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo: Oleg Bartunov (Phu Monastery and Bhrikuti Peak, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is”, and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Multiversion Concurrency Control
  • Data Snapshot
  • Isolation Levels
  • Vacuuming and its Horizon
  • Locking
  • Transaction Status

Page 3: MVCC

Source slide page 3: MVCC{#source-figure-003 caption=“Source slide page 3: MVCC”}

Storing multiple versions of the same row

  • versions have different validity time
  • timestamp = transaction ID (IDs are given in ascending order)

Speaker notes

When multiple sessions are running at the same time, two transactions may access the same row at the same time. If both transactions are just reading the row, there is no problem. If both transactions try to write, no problem either (in this case, they line up and make the changes one after the other). The tricky part is when one transaction wants to read a row and another one wants to change it at the same time.

There are two simple ways about it. You can let such transactions block each other, but then performance suffers. Otherwise, you can let the reading transaction immediately see the changes made by the writing transaction, even if they are not committed (this is called a “dirty read”). This is dangerous, because the changes can be rolled back.

PostgreSQL goes the hard way and utilizes what is known as Multiversion concurrency control. In essence, the system stores multiple versions of each row. So, a writing transaction operates on its own version, while a reading transaction sees its own version.

To distinguish between the versions, PostgreSQL marks each one with two timestamps, which together specify a version’s “validity time”.

The timestamps are essentially just transaction IDs, which always come in ascending order. (In reality, the whole thing is a bit more complicated, but not worth getting into right now.) Upon creation, a row version is marked with the ID of the transaction that executed the INSERT command. When deleted, the version is marked with the ID of the transaction that did the DELETE command (but is not physically deleted). An UPDATE command is a DELETE and an INSERT executed back to back.

https://postgrespro.com/docs/postgresql/16/mvcc-intro

Page 4: Data Snapshot

Source slide page 4: Data Snapshot{#source-figure-004 caption=“Source slide page 4: Data Snapshot”}

Representation of database data in a consistent state at a specific point in time

  • transaction ID defines the point in time
  • list of active transactions helps the system
  • to exclude changes that have not been committed

Speaker notes

PostgreSQL uses snapshot-based transaction isolation.

A transaction accessing a table should see only one of the versions of each row (or none at all). To achieve this, PostgreSQL presents the transaction with a data snapshot created at a certain point in time. The snapshot includes the most recent versions of all committed rows but does not include any non-committed changes from active transactions. In other words, the snapshot takes the version of each row that corresponds to the moment when the snapshot was created.

A snapshot is not a physical copy of the data, but just a few numbers:

● the ID of the last committed transaction at the time of snapshot,

● list of active transactions at that point in time.

The list is needed in order to exclude from the snapshot any changes those transactions may have made but not yet committed.

With just these numbers, we can always tell which row version will be visible in the snapshot. Sometimes it will be the current (most recently committed) version, as with row 1 in the diagram. Sometimes not: row 2 has been deleted after the snapshot has been created (and the change has already been committed), but the transaction still continues to see it while working with the snapshot. This is the correct behavior, the snapshot gives a consistent representation of data at the selected point in time.

Some rows will not get into the snapshot at all: row 3 was deleted before the snapshot was made, so it is not included.

Page 5: Isolation Levels

Read Uncommitted

  • not supported by PostgreSQL: works as Read Committed
  • Read Committed (default)
    • the snapshot is created as of the time a statement starts
    • repeated query may return different data
  • Repeatable Read
    • the snapshot is created as of the time the first transaction statement starts
    • a transaction may fail with a serialization error
  • Serializable
    • total isolation, but additional overhead
    • a transaction may fail with a serialization error

Speaker notes

The SQL standard defines four isolation levels: the stricter the level, the less concurrent transactions affect each other. At the time when the standard was adopted, it was believed that the stricter the level, the more difficult it is to implement and the stronger its impact on performance (since then, these views have changed somewhat).

The most lax level of Read Uncommitted allows dirty reads. It is not supported by PostgreSQL, because it is of no practical value and does not give a performance gain.

The Read Committed level is the default isolation level in PostgreSQL. At this level, data snapshots are created at the beginning of each SQL statement execution. Thus, the statement works with an unchanged and consistent data snapshot, but two identical queries following one after the other may show different data.

At the Repeatable Read level, the snapshot is built at the beginning of a transaction (when executing the first statement). This makes all queries inside the same transaction see the same data. This level is convenient, for example, for generating reports from several queries.

The Serializable level guarantees total isolation. At this level, you can use any statements as if the transaction is running alone. The drawback is that some transactions will fail, and your application must be able to repeat such transactions.

https://postgrespro.com/docs/postgresql/16/transaction-iso

Page 6: Row Version Visibility

How do you verify that the same row can exist in multiple versions? Create a table:

=> CREATE TABLE t(s text);
CREATE TABLE

And insert one row. Remember that if you do not explicitly start a transaction with BEGIN, psql executes the command and immediately commits the result:

=> INSERT INTO t VALUES ('Version one');
INSERT 0 1

Start the transaction and get its ID:

=> BEGIN;
BEGIN
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                736
(1 row)

The transaction sees the first (and so far the only) version of the row:

=> SELECT *, xmin, xmax FROM t;
      s      | xmin | xmax
-------------+------+------
 Version one |  735 |    0
(1 row)

Here, the hidden columns show the transaction IDs that limit the visibility of the row version: xmin is the ID of the previous transaction which created the version, and xmax=0 means that this version is current. Now start another transaction in another session:

=> BEGIN;
BEGIN
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                737
(1 row)

The transaction sees the same and only version:

=> SELECT *, xmin, xmax FROM t;
      s      | xmin | xmax
-------------+------+------
 Version one |  735 |    0
(1 row)

Now change the row from within the second transaction.

=> UPDATE t SET s = 'Version two';
UPDATE 1

This is what we get:

=> SELECT *, xmin, xmax FROM t;

Page 7: Page 7

      s      | xmin | xmax
-------------+------+------
 Version two |  737 |    0
(1 row)

What will the first transaction see?

=> SELECT *, xmin, xmax FROM t;
      s      | xmin | xmax
-------------+------+------
 Version one |  735 |  737
(1 row)

Since the change is not yet committed, the first transaction continues to see the first version of the row.

Note

Note the xmax value: it indicates that another transaction is currently changing the row. Generally speaking, such “peeping” violates isolation, so the xmin and xmax fields are hidden and should not be used in production. Now commit the changes.

=> COMMIT;
COMMIT

What will the first transaction see now?

=> SELECT *, xmin, xmax FROM t;
      s      | xmin | xmax
-------------+------+------
 Version two |  737 |    0
(1 row)

The first transaction also sees the second version of the string. After commit, the first version of the row is no longer visible in any transaction.

=> COMMIT;
COMMIT

Page 8: Vacuuming and its Horizon

Source slide page 8: Vacuuming and its Horizon{#source-figure-008 caption=“Source slide page 8: Vacuuming and its Horizon”}

There is a single horizon for each database

  • Long-running transactions can hold back the horizon
    • thereby blocking the vacuuming of outdated row versions

Speaker notes

MVCC makes it possible to effectively implement snapshot-based isolation, but as a result, old (“dead”) row versions (“dead tuples”) accumulate in table pages.

Historical versions are needed for some time so that transactions can work with their data snapshots and, in case of rollback, get back to old values. For each database, there is a transaction ID (xid), so that all historical versions deleted by transactions with lower xids are no longer visible in any snapshot. This xid is called vacuum horizon.

Vacuuming is performed by special background processes; but can also be executed manually using the VACUUM command.

Long-running transactions and queries can hold back the horizon, which prevents the removal of accumulated row versions. If you do not vacuum historical data in a timely manner, tables and indexes will bloat, consuming excessive disk space, and the search for current row versions will slow down.

https://postgrespro.com/docs/postgresql/16/routine-vacuuming

Page 9: Locking

Row locks

  • reading never locks rows
  • changing rows locks them for changes, but not for reads
  • Table locks
    • prohibit changing or deleting a table while it is being worked on
    • prohibit reading the table when rebuilding or moving
    • etc.
  • Lock lifetime
    • set as needed or manually
    • released automatically upon transaction completion

Speaker notes

So what does MVCC provide? It allows the system to have only the most necessary minimum of locks, thereby increasing performance.

The main locks are set at the row level. Reading never blocks either reading or writing transactions. Changing a row does not lock it for reading. The only case when a transaction will wait for a lock to be released is if it tries to change a row that has already been changed by another transaction that has not been committed yet.

Locks can also be set at a higher level, particularly on tables. They are needed so that no one can delete the table while other transactions are reading data from it, or to prohibit access to the table being rebuilt. Such locks generally do not cause problems, since deleting or rebuilding tables is only done once in a while. However, some changes of table structure trigger implicit table rebuilding, completely blocking access to the table and its indexes. Locks are explained in detail in the Locking module of the DBA2 course.

All necessary locks are set automatically and automatically removed at the end of the transaction. However, if a lock was acquired after the savepoint was set, it will be released immediately when rolling back to that savepoint.

You can also set additional custom locks, but this is rarely necessary.

https://postgrespro.com/docs/postgresql/16/explicit-locking

Page 10: Locking

Repeat the experiment, but now let both transactions try to change the same row.

=> BEGIN;
BEGIN
=> UPDATE t SET s = 'Version three' RETURNING *;
       s
---------------
 Version three
(1 row)
UPDATE 1

And in the second transaction:

=> BEGIN;
BEGIN
=> UPDATE t SET s = 'Version four' RETURNING *;

The second transaction “hung up”: it cannot change the row until the first transaction releases the lock.

=> COMMIT;
COMMIT

Now the second transaction can continue:

      s
--------------
 Version four
(1 row)
UPDATE 1
=> COMMIT;
COMMIT

Both transactions have committed their changes. The first session reads the table again and sees the current row: this is the result committed by the second transaction:

=> SELECT * FROM t;
      s
--------------
 Version four
(1 row)

Page 11: Transaction Status

Transaction status (clog)

  • service information; two bits per transaction
  • stored in files on disk
  • cached in shared memory
  • Commit
    • the “transaction committed” bit is set
  • Termination
    • the “transaction aborted” bit is set
    • performed as fast as commit (no data rollback needed)

Speaker notes

For multiversion concurrency control to work, the server needs to understand the status of transactions. A transaction can be active or finished. A transaction can end either in a commit or in an abort. Therefore, two bits are required to represent the state of each transaction.

Transaction statuses (commit log, clog) are stored in special service files in the PGDATA/pg_xact directory and worked upon in the server’s shared memory, as to avoid constantly accessing the disk.

At any transaction completion (either successful or not), it is enough to set the appropriate status bits. Both transaction commit and abort occur equally quickly.

If an aborted transaction managed to create new row versions, these versions are not destroyed (there is no “physical” rollback of data). Thanks to the status information, other transactions will see that the transaction that created or deleted the row versions was actually aborted, and will not take changes made by it into account.

Page 12: Takeaways

Multiple versions of each row can be stored in data files

  • Transactions work with data snapshots, representations of database data in a consistent state at a specific point in time
  • Isolation levels have different snapshot creation times
  • Dead row versions beyond the vacuum horizon must be periodically vacuumed
  • Writers do not block readers, readers do not block anyone

Page 13: Practice

  1. Create a table with one row. Begin a transaction at the Read Committed isolation level and query the table. In another session, delete the row and commit the changes. How many rows will the first transaction see after executing the same query again? Try and see. Complete the first transaction. 2. Repeat the same thing, but now let the transaction work at the isolation level Repeatable Read:
  • BEGIN ISOLATION LEVEL REPEATABLE READ;
  • Explain the differences.

Page 14: 1. Read Committed Isolation Level

Create a table:

=> CREATE TABLE t(n integer);
CREATE TABLE
=> INSERT INTO t VALUES (42);
INSERT 0 1

Query from the first transaction (default isolation level is Read Committed):

=> BEGIN;
BEGIN
=> SELECT * FROM t;
 n
----
 42
(1 row)

Delete the row in the second transaction and commit the changes:

=> DELETE FROM t;
DELETE 1

Repeat the query:

=> SELECT * FROM t;
 n
---
(0 rows)

The first transaction sees the change: the row has been deleted.

=> COMMIT;
COMMIT

2. Repeatable Read Isolation Level

Return the row:

=> INSERT INTO t VALUES (42);
INSERT 0 1

Query from the first transaction:

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT * FROM t;
 n
----
 42
(1 row)

Delete the row in the second transaction and commit the changes:

=> DELETE FROM t;
DELETE 1

Repeat the query:

=> SELECT * FROM t;

Page 15: Page 15

 n
----
 42
(1 row)

At this isolation level, the first transaction does not see any changes: the row still exists for it.

=> COMMIT;
COMMIT

Page 16: Practice+

  1. Begin a transaction and create a new table with one row. Without completing the transaction, open a second session and query the table in it. Check what the transaction will return in the second session.
  • Commit the transaction in the first session and repeat the query
  • to the table in the second session.
    1. Repeat task 1, but roll back rather than commit the transaction in the first session. What has changed?
    1. In the first session, start a transaction and make a query to the previously created table. Will it be possible to delete this table in the second session before the transaction is completed? Try and see.

Page 17: 1. Transactions and DDL Commands: Commit

Start a transaction and create a new table:

=> BEGIN;
BEGIN
=> CREATE TABLE t1(n integer);
CREATE TABLE
=> INSERT INTO t1 VALUES (42);
INSERT 0 1

In the second session, query the table:

=> SELECT * FROM t1;
ERROR:  relation "t1" does not exist
LINE 1: SELECT * FROM t1;
                      ^

Until the transaction that created the table is completed, all other transactions do not see the table. The table will be visible only after the completion of the transaction that created it:

=> COMMIT;
COMMIT
=> SELECT * FROM t1;
 n
----
 42
(1 row)

2. Transactions and DDL Commands: Rollback

Start a transaction and create a new table:

=> BEGIN;
BEGIN
=> CREATE TABLE t2(n integer);
CREATE TABLE
=> INSERT INTO t2 VALUES (42);
INSERT 0 1

The query in the second session sees no changes, as expected:

=> SELECT * FROM t2;
ERROR:  relation "t2" does not exist
LINE 1: SELECT * FROM t2;
                      ^

When the first transaction is rolled back, the table creation command is also rolled back:

=> ROLLBACK;
ROLLBACK
=> SELECT * FROM t2;
ERROR:  relation "t2" does not exist
LINE 1: SELECT * FROM t2;
                      ^

In PostgreSQL, DDL commands are transactional.

3. Table Locks

Page 18: Page 18

Start a transaction, query the table:

=> BEGIN;
BEGIN
=> SELECT * FROM t1;
 n
----
 42
(1 row)

The second transaction, which is trying to remove the table is locked: you cannot remove a table that is being used.

=> DROP TABLE t1;

The table will be removed only after the first transaction is completed:

=> COMMIT;
COMMIT
DROP TABLE

04 Lesson 04: Architecture / Buffer Cache and WAL

Native Markdown source page: Architecture / Buffer Cache and WAL
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_04_arch_wal_overview.html Archive: DEV1-handouts-16-en.zip PDF pages: 17

Page 1: Architecture / Buffer Cache and WAL

© Postgres Professional, 2017–2025 Authors Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti Peak, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Buffer Cache
  • Replacement Algorithm
  • Write-Ahead Log
  • Checkpoint
  • Processes Related to the Buffer Cache and WAL

Page 3: Buffer Cache

Source slide page 3: Buffer Cache{#source-figure-003 caption=“Source slide page 3: Buffer Cache”}

Buffer array

  • data page (8 KB)
  • additional information
  • “Dirty” buffers
    • asynchronous write
  • Locks in memory
    • for shared access

Speaker notes

The buffer cache is used to smooth out the difference between the RAM and disk speed. It consists of an array of buffers which contain data pages and some additional information (for example, the file name and the position of the page inside this file).

The page size is usually 8 KB; the size can only be changed when building PostgreSQL.

Any work with data pages goes through the buffer cache. If any process is going to work with the page, it first tries to find it in the cache. If the page does not exist, the process requests the operating system to read this page and places it in the buffer cache. (Note that the OS can read the page either from disk or from its own cache.)

After the page is written to the buffer cache, it can be accessed repeatedly without the overhead of operating system calls.

If a process has changed the data in the page, the corresponding buffer becomes “dirty”. The modified page must be written on disk, but for performance reasons, the recording occurs asynchronously and may be delayed.

The buffer cache, like other shared memory structures, is protected by locks to control concurrent access. Although locks are implemented effectively, access to the buffer cache is not nearly as fast as simply accessing RAM. Therefore, in general, the less data a query reads and modifies, the faster it will work.

Page 4: Replacement

Source slide page 4: Replacement{#source-figure-004 caption=“Source slide page 4: Replacement”}

Least Recently Used replacement

  • dirty buffer is
  • written on disk
  • another page is read
  • into the vacant space

Speaker notes

The buffer cache size is usually not so large as to fit the entire database. It is limited by the available RAM. Also, the larger the buffer cache, the greater the overhead. Therefore, when reading the next page, sooner or later the buffer cache has to run out of space. In this case, page replacement happens.

The replacement algorithm selects a page in the cache that has been used less often than others. If the selected buffer is dirty, the page is written on disk first to store the changes made to it. Then, a new page is written into the buffer.

This replacement is called LRU (Least Recently Used). It keeps the most frequently accessed data in the cache. Such “hot” blocks of data are not very common, and this approach helps to significantly reduce the number of requests to OS (and disk operations), provided enough cache memory.

Page 5: The Impact of Buffer Cache on Query Execution

Creating a new database in the cluster and connecting to it (for more details about databases, see the Data Organization module):

=> CREATE DATABASE arch_wal_overview;
CREATE DATABASE
=> \c arch_wal_overview
You are now connected to database "arch_wal_overview" as user "student".

Create a table:

=> CREATE TABLE t(n integer);
CREATE TABLE

Populate it with rows:

=> INSERT INTO t SELECT id FROM generate_series(1,100_000) AS id;
INSERT 0 100000

The shared_buffers parameter indicates the buffer cache size:

=> SHOW shared_buffers;
 shared_buffers
----------------
 128MB
(1 row)

The default value is too low. In the real world, you should increase it immediately after server installation (it will be applied after restart). Restart the server to wipe the cache clean.

=> \q
student$ sudo pg_ctlcluster 16 main restart
student$ psql arch_wal_overview

Now, let’s compare the behaviour of the system as we run a query once, and then the same query again. Query plans is not the topic of this course, but we will peek into them every now and again. The EXPLAIN ANALYZE command used below will execute the query as well as display the execution plan and some extra details:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM t;
                 QUERY PLAN
--------------------------------------------
 Seq Scan on t (actual rows=100000 loops=1)
   Buffers: shared read=443
 Planning:
   Buffers: shared hit=12 read=8 dirtied=1
 Planning Time: 0.308 ms
 Execution Time: 23.718 ms
(6 rows)

The “Buffers: shared” line shows the buffer utilization. read — the number of buffers where pages had to be read from disk.

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM t;
                 QUERY PLAN
--------------------------------------------
 Seq Scan on t (actual rows=100000 loops=1)
   Buffers: shared hit=443
 Planning Time: 0.315 ms
 Execution Time: 14.299 ms
(4 rows)

hit — the number of buffers where requested pages were found.

Page 6: Page 6

Note

Note Note that on the second query execution, not only the execution time went down, but the planning time too (because system catalog pages are cached as well).

Page 7: Write-ahead Log (WAL)

Problem: when a crash occurs, data from RAM that is not written on disk is lost

  • WAL
    • stream of records of the actions being performed;
    • can be used to redo the steps lost during the crash
    • records are written to disk earlier than the changed data
  • WAL tracks changes to
    • pages in tables, indexes and other objects
    • transaction status (clog)
  • WAL does not track changes to
    • temporary and unlogged tables

Speaker notes

Having a buffer cache (and other buffers in RAM) increases performance at the cost of reliability. When a crash happens, all buffer cache content is lost. If the crash occurs on the OS or hardware level, the content of OS buffers will also be lost (the OS may have its own failsafes for this).

To increase reliability, PostgreSQL uses the Write-ahead log. When performing any operation, the WAL records minimum necessary information about the operation to be able to perform it again. The record must be written into the disk (or another persistent storage) before the data modified by the operation is (that is why it is called Write-ahead log).

WAL files are located in the PGDATA/pg_wal directory.

All objects that are being worked on in RAM have their operations logged. These include tables, indexes and other objects, as well as transaction statuses. Operations with temporary tables (tables which exist only during the scope of a session or a transaction and are only available to the user who has created them) are not logged. You can also set a regular table to be explicitly unlogged. The table will be quicker to work with, but will be wiped on crash.

https://postgrespro.com/docs/postgresql/16/wal-intro

Page 8: Write-Ahead Log

The WAL can be considered as a continuous stream of records. Each record has a unique ID called an LSN (Log Sequence Number). This 64-bit number represents the record’s byte offset from the start of the WAL. The current WAL position can be seen with pg_current_wal_lsn function:

=> SELECT pg_current_wal_lsn();
 pg_current_wal_lsn
--------------------
 0/2378568
(1 row)

The position is displayed as two 32-bit numbers separated by a slash. Let’s save it for future reference. Now let’s perform some operations and see what’s changed.

=> UPDATE t SET n = 100_001 WHERE n = 1;
UPDATE 1
=> SELECT pg_current_wal_lsn();
 pg_current_wal_lsn
--------------------
 0/237B580
(1 row)

It’s not the absolute values we’re interested in, but the distance between them, as it shows the size of generated WAL records in bytes:

=> SELECT '0/237B580'::pg_lsn - '0/2378568'::pg_lsn AS bytes;
 bytes
-------
 12312
(1 row)

The WAL is stored in files in a separate catalog (PGDATA/pg_wal). By default, the files are 16 MB each, but you can change that during cluster initialization. In addition to browsing the files by means of the OS, you can also display them by the following command:

=> SELECT * FROM pg_ls_waldir() ORDER BY name LIMIT 10;
           name           |   size   |      modification
--------------------------+----------+------------------------
 000000010000000000000002 | 16777216 | 2025-11-27 14:18:39+03
 000000010000000000000003 | 16777216 | 2025-11-27 14:18:33+03
(2 rows)

Page 9: Checkpoint

Source slide page 9: Checkpoint{#source-figure-009 caption=“Source slide page 9: Checkpoint”}

Regular flushing of all dirty buffers to disk

  • ensures that all data changes before the checkpoint get to the disk
  • limits the size of the WAL required for recovery
  • Crash recovery
    • starts from the last checkpoint
    • WAL records are replayed one-by-one to restore data

Speaker notes

When PostgreSQL crashes, it enters the recovery mode on the next start. The data on disk at this point is inconsistent. Changes to hot pages were in the buffer cache, and are now lost, while some of the later changes have been flushed to disk already.

To restore consistency, PostgreSQL sequentially reads the WAL records, replaying the changes that did not make it to the disk. This way, the state of all transactions at the time of the crash is restored. Then, any transactions that have not been logged as committed are considered aborted.

However, logging all changes throughout a server’s lifetime and replaying everything from day one after each crash is impractical, if not impossible. Instead, PostgreSQL uses checkpoints. Every now and then, it forces all dirty buffers to disk (including clog buffers, which store transaction statuses).

A checkpoint is the moment in time when the flushing of all data to disk is started. However, you only have a valid checkpoint when the flushing of all such buffers is complete. It ensures that all data changes up to this point are safe in persistent memory.

In production environments with a large buffer cache, a checkpoint can flush many dirty buffers, so the server spreads this flushing over time to smooth out the I/O load.

When a crash occurs, recovery is started from the last completed checkpoint. Consequently, it is sufficient to store WAL files only as far back as the last completed checkpoint goes.

Page 10: Crash Recovery Using WAL

Modified table pages exist in the buffer cache but haven’t been written to disk yet. During normal shutdown, the server performs a checkpoint to flush all dirty pages to disk. However, we’ll simulate a system crash by sending a signal to the postmaster process.

student$ sudo kill -QUIT $(sudo head -n 1 /var/lib/postgresql/16/main/postmaster.pid)

When the server comes back up, it should begin the recovery. Let’s try:

student$ sudo pg_ctlcluster 16 main start
student$ psql arch_wal_overview
=> SELECT min(n), max(n) FROM t;
 min |  max
-----+--------
   2 | 100001
(1 row)

All the changes have been recovered. After performing a checkpoint, PostgreSQL automatically deletes WAL files that are no longer necessary for recovery.

Page 11: Performance

Source slide page 11: Performance{#source-figure-011 caption=“Source slide page 11: Performance”}

Synchronous mode

  • write on commit
  • backend
  • Asynchronous mode
    • background write
    • walwriter

Speaker notes

The WAL approach is faster than working directly with disk without a buffer cache. Firstly, a WAL record is smaller than an entire page of data. Secondly, the WAL is written sequentially (and usually not read until a crash occurs), which is better for basic hard disk drives.

Performance can also be managed via configuration settings. If the records are stored to disk immediately (synchronous mode), this guarantees that the committed transaction will not be lost. But recording to disk is expensive and forces the committing backend process to wait in line. To prevent WAL records from getting “stuck” in the OS cache, PostgreSQL relies on call of the fsync function, which forces the data into persistent storage.

There is also asynchronous mode, which has a background process walwriter constantly flushing WAL records to disk, with a certain delay. It is more efficient at the cost of some reliability, but still ensures consistency after crash recovery.

In fact, both modes work together. WAL records of a long transaction are written asynchronously (to free up WAL buffers). And if a page is getting flushed to disk and the corresponding WAL record is not there yet, it will immediately be flushed in synchronous mode.

Page 12: Main Processes

Source slide page 12: Main Processes{#source-figure-012 caption=“Source slide page 12: Main Processes”}

WAL writer

  • Checkpointer
    • flush all
    • dirty buffers
  • Background writer
    • flush some
    • dirty buffers
  • Backends
    • flush replaced
    • dirty buffer

Speaker notes

Let’s take a step back and look at the processes that maintain the buffer cache and the WAL.

First, there is walwriter. This process writes WAL records to disk in asynchronous mode. In synchronous mode, this job is handled by the backend that commits the transaction.

Second, checkpointer, the checkpoint process. It periodically flushes all dirty buffers to disk.

Third, bgwriter (or background writer). It operates similarly to checkpointer, but it only flushes some of the dirty buffers, specifically, those that are most likely to be replaced soon. It frees up buffer space so that when backend selects a buffer to put a new page in, it does not have to flush the old contents of the buffer to disk itself.

Fourth, there are backends that put data into the buffer cache. Whenever a buffer being replaced is still dirty (despite the efforts of checkpointer and bgwriter), the backend will flush it to disk.

Page 13: WAL Levels

Minimal

  • guarantees crash recovery
  • Replica (default)
    • backup
    • replication: transmit the WAL on another server and replay it there
  • Logical
    • logical replication: information about adding, changing,
    • and deleting table rows

Speaker notes

WAL was developed as a data protection tool to mitigate the risk of data loss due to crashes.

However, the WAL mechanism turned out to have other applications, if it is supplemented with additional information.

The data stored in the WAL is controlled by the wal_level parameter.

● The minimal level is sufficient to recover after a crash, and nothing else.

● The replica level stores additional information that allows WAL to be used for backup and replication. During replication, WAL records are transmitted to another server and applied there, creating an exact copy (replica) of the original server.

● At the logical level, information is added to the WAL that allows decoding “physical” WAL records and forming “logical” records of adding, changing and deleting table rows. This enables logical replication (see corresponding lessons of DEV2 and DBA3 courses for details).

Page 14: Takeaways

Buffer cache increases performance by reducing the number of disk operations

  • WAL ensures reliability
  • WAL size is kept in check by checkpoints
  • WAL has multiple uses:
    • crash recovery
    • backup
    • replication

Page 15: Practice

  1. Using the OS tools, find the processes responsible for the buffer cache and the WAL. 2. Stop PostgreSQL in fast mode; start it again. Check the server message log. 3. Now stop PostgreSQL in immediate mode; start it again. Check the server message log and compare with the previous one.

Speaker notes

  1. To stop in fast mode, use the command
pg_ctlcluster 16 main stop

This makes the server abort all open connections and perform a checkpoint before shutting down, so that all data is flushed to disk and consistent. In this mode, the shutdown may take some time, but on startup the server will be good to go right away.

  1. To stop in immediate mode, use the command
pg_ctlcluster 16 main stop -m immediate --skip-systemctl-redirect

The server will also abort open connections, but will not perform a checkpoint. Data on disk will be inconsistent, like after a crash. In this mode, the server shuts down quickly, but will have to restore data consistency using WAL on startup.

If your PostgreSQL is compiled from source code, the fast stop command will be

pg_ctl stop

and the immediate stop command will be

pg_ctl stop -m immediate

Page 16: 1. Operating System Processes

First, get the postmaster process ID. It is stored in the first line of postmaster.pid. This file is located in the data directory and is created every time the server starts.

student$ sudo cat /var/lib/postgresql/16/main/postmaster.pid
49039
/var/lib/postgresql/16/main
1764242838
5432
/var/run/postgresql
localhost
  1336636        57
ready

Now, find all the processes spawned by postmaster:

student$ sudo ps -o pid,command --ppid 49039
    PID COMMAND
  49040 postgres: 16/main: checkpointer
  49041 postgres: 16/main: background writer
  49043 postgres: 16/main: walwriter
  49044 postgres: 16/main: autovacuum launcher
  49045 postgres: 16/main: logical replication launcher
  49088 postgres: 16/main: student student [local] idle

The processes that serve the buffer cache and the WAL include: checkpointer background writer walwriter

2. Stop in Fast Mode

To easily separate old messages from new ones, clear the message log before restarting the server. Do not do this on a real production server.

student$ sudo -u postgres truncate -cs0 /var/log/postgresql/postgresql-16-main.log
student$ sudo pg_ctlcluster 16 main stop
student$ sudo pg_ctlcluster 16 main start

Server message log:

student$ cat /var/log/postgresql/postgresql-16-main.log
2025-11-27 14:27:22.121 MSK [49039] LOG:  received fast shutdown request
2025-11-27 14:27:22.125 MSK [49039] LOG:  aborting any active transactions
2025-11-27 14:27:22.128 MSK [49088] student@student FATAL:  terminating connection due to
administrator command
2025-11-27 14:27:22.143 MSK [49039] LOG:  background worker "logical replication
launcher" (PID 49045) exited with exit code 1
2025-11-27 14:27:22.143 MSK [49040] LOG:  shutting down
2025-11-27 14:27:22.147 MSK [49040] LOG:  checkpoint starting: shutdown immediate
2025-11-27 14:27:22.485 MSK [49040] LOG:  checkpoint complete: wrote 4 buffers (0.0%); 0
WAL file(s) added, 0 removed, 0 recycled; write=0.034 s, sync=0.009 s, total=0.342 s;
sync files=3, longest=0.004 s, average=0.003 s; distance=0 kB, estimate=0 kB;
lsn=0/1921AB0, redo lsn=0/1921AB0
2025-11-27 14:27:22.491 MSK [49039] LOG:  database system is shut down
2025-11-27 14:27:22.877 MSK [49341] LOG:  starting PostgreSQL 16.11 (Ubuntu
16.11-1.pgdg24.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu
13.3.0-6ubuntu2~24.04) 13.3.0, 64-bit
2025-11-27 14:27:22.878 MSK [49341] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2025-11-27 14:27:22.881 MSK [49341] LOG:  listening on Unix socket
"/var/run/postgresql/.s.PGSQL.5432"
2025-11-27 14:27:22.893 MSK [49344] LOG:  database system was shut down at 2025-11-27
14:27:22 MSK
2025-11-27 14:27:22.908 MSK [49341] LOG:  database system is ready to accept connections

3. Stop in Immediate Mode

student$ sudo -u postgres truncate -cs0 /var/log/postgresql/postgresql-16-main.log

Page 17: Page 17

student$ sudo pg_ctlcluster 16 main stop -m immediate --skip-systemctl-redirect
student$ sudo pg_ctlcluster 16 main start

Server message log:

student$ cat /var/log/postgresql/postgresql-16-main.log
2025-11-27 14:27:25.524 MSK [49341] LOG:  received immediate shutdown request
2025-11-27 14:27:25.541 MSK [49341] LOG:  database system is shut down
2025-11-27 14:27:25.975 MSK [49529] LOG:  starting PostgreSQL 16.11 (Ubuntu
16.11-1.pgdg24.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu
13.3.0-6ubuntu2~24.04) 13.3.0, 64-bit
2025-11-27 14:27:25.976 MSK [49529] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2025-11-27 14:27:25.979 MSK [49529] LOG:  listening on Unix socket
"/var/run/postgresql/.s.PGSQL.5432"
2025-11-27 14:27:25.991 MSK [49532] LOG:  database system was interrupted; last known up
at 2025-11-27 14:27:22 MSK
2025-11-27 14:27:35.796 MSK [49532] LOG:  database system was not properly shut down;
automatic recovery in progress
2025-11-27 14:27:35.805 MSK [49532] LOG:  invalid record length at 0/1921B28: expected at
least 24, got 0
2025-11-27 14:27:35.806 MSK [49532] LOG:  redo is not required
2025-11-27 14:27:35.823 MSK [49530] LOG:  checkpoint starting: end-of-recovery immediate
wait
2025-11-27 14:27:35.852 MSK [49530] LOG:  checkpoint complete: wrote 3 buffers (0.0%); 0
WAL file(s) added, 0 removed, 0 recycled; write=0.009 s, sync=0.005 s, total=0.035 s;
sync files=2, longest=0.003 s, average=0.003 s; distance=0 kB, estimate=0 kB;
lsn=0/1921B28, redo lsn=0/1921B28
2025-11-27 14:27:35.862 MSK [49529] LOG:  database system is ready to accept connections

Before starting to accept connections, the DBMS performed an automatic recovery.

05 Lesson 05: Data Organization / Logical Structure

Native Markdown source page: Data Organization / Logical Structure
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_05_data_logical.html Archive: DEV1-handouts-16-en.zip PDF pages: 19

Page 1: Data Organization / Logical Structure

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Databases and Templates
  • Schemas and Search Path
  • Special Schemas
  • System Catalog

Page 3: Database Cluster

Source slide page 3: Database Cluster{#source-figure-003 caption=“Source slide page 3: Database Cluster”}

Cluster initialization creates three databases

  • A new database is always cloned from an existing one
CREATE DATABASE

Speaker notes

A PostgreSQL instance manages multiple databases. This is known as a database cluster. During cluster initialization (which is performed either automatically during installation or manually using the initdb command), three identical databases are created. All other databases created by the user are cloned from an existing one.

By default, the template1 DB is used as the source for creating new databases. Any objects and extensions added to the template will be copied into any database created from it.

The template0 database must never be modified. It is required in at least two scenarios. First, to restore the database from a backup made by the pg_dump utility (this is discussed in the Logical Backup lesson). Secondly, it is used when creating a new database with an encoding different from the one specified during cluster initialization (this is discussed in the DBA2 course).

The postgres database is used to connect to by default by the postgres user. It is not a hard requirement to have it, but some utilities expect the postgres database to be there, so removing it is not a good idea, even if you never use the database directly.

https://postgrespro.com/docs/postgresql/16/manage-ag-templatedbs

Page 4: Databases

To obtain the list of databases, open psql and run:

=> \l
                                                       List of databases
   Name    |  Owner   | Encoding | Locale Provider |   Collate   |    Ctype    | ICU
Locale | ICU Rules |   Access privileges
-----------+----------+----------+-----------------+-------------+-------------+----------
--+-----------+-----------------------
 postgres  | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
  |           |
 student   | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
  |           |
 template0 | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
  |           | =c/postgres          +
           |          |          |                 |             |             |
  |           | postgres=CTc/postgres
 template1 | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
  |           | =c/postgres          +
           |          |          |                 |             |             |
  |           | postgres=CTc/postgres
(4 rows)

The student database is there for the student user to connect to more easily. There are some columns in the output that are of no interest for us for now. When a database is created, the template1 template is used by default.

=> CREATE DATABASE data_logical;
CREATE DATABASE
=> \c data_logical
You are now connected to database "data_logical" as user "student".
=> \l
                                                         List of databases
     Name     |  Owner   | Encoding | Locale Provider |   Collate   |    Ctype    | ICU
Locale | ICU Rules |   Access privileges
--------------+----------+----------+-----------------+-------------+-------------+-------
-----+-----------+-----------------------
 data_logical | student  | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           |
 postgres     | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           |
 student      | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           |
 template0    | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           | =c/postgres          +
              |          |          |                 |             |             |
     |           | postgres=CTc/postgres
 template1    | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           | =c/postgres          +
              |          |          |                 |             |             |
     |           | postgres=CTc/postgres
(5 rows)

Page 5: Schemas

Namespaces for objects

  • divide objects into logical groups
  • prevent name conflicts between applications
  • Schemas and users are different entities
  • Special schemas
    • public — all objects are created here by default
    • pg_catalog — system catalog
    • information_schema — an alternative variant of the system catalog
    • pg_temp — a storage for temporary tables
    • …

Speaker notes

Schemas are namespaces for database objects. They separate objects into groups for easier management and serve to prevent name conflicts when multiple users access the same database, or when multiple applications or extensions are installed.

In PostgreSQL, schema and user are different entities (although the default settings allow users to seamlessly operate schemas of the same name).

There are several special schemas usually present in any database.

The public schema is used by default for storing objects, unless intentionally configured otherwise.

The pg_catalog schema stores system catalog objects. The system catalog is a collection of tables containing metadata about objects belonging to the cluster. It is itself stored in the cluster. The information_schema is another one with an alternative representation of the system catalog (as defined in the SQL standard).

The pg_temp schema stores temporary tables. (In fact, temporary tables are created in schemas called pg_temp_1, pg_temp_2, and so on: each user has its own schema, but they are all referred to as pg_temp.)

There are other special schemas, more technical in nature.

https://postgrespro.com/docs/postgresql/16/ddl-schemas

Page 6: Cluster Databases and Schemas

Source slide page 6: Cluster Databases and Schemas{#source-figure-006 caption=“Source slide page 6: Cluster Databases and Schemas”}

Speaker notes

Schemas belong to databases, and all database objects are distributed between schemas.

However, several system catalog tables store information that is shared by the entire cluster: the list of databases, the list of users, and some other data. These tables are stored outside of any single database, but at the same time they are accessible from any database within the cluster.

This way, a client connected to a database can see descriptions of not only objects that belong to the database but also of cluster-wide objects. Descriptions of objects in other databases, however, cannot be accessed without connecting to the databases first.

Page 7: Schemas

To display schemas in psql, use \dn command (= describe namespace):

=> \dn
      List of schemas
  Name  |       Owner
--------+-------------------
 public | pg_database_owner
(1 row)

It does not show any system schemas, though. To list them, add the ’S’ flag (it works for many other commands too):

=> \dnS
            List of schemas
        Name        |       Owner
--------------------+-------------------
 information_schema | postgres
 pg_catalog         | postgres
 pg_toast           | postgres
 public             | pg_database_owner
(4 rows)

We have already talked about some of these schemas (public, pg_catalog, information_schema); we will discuss the rest in other lectures. Another useful flag is the plus sign, which displays additional information:

=> \dn+
                                       List of schemas
  Name  |       Owner       |           Access privileges            |      Description
--------+-------------------+----------------------------------------+--------------------
----
 public | pg_database_owner | pg_database_owner=UC/pg_database_owner+| standard public
schema
        |                   | =U/pg_database_owner                   |
(1 row)

Create a new schema:

=> CREATE SCHEMA special;
CREATE SCHEMA
=> \dn
       List of schemas
  Name   |       Owner
---------+-------------------
 public  | pg_database_owner
 special | student
(2 rows)

Create a table:

=> CREATE TABLE t(n integer);
CREATE TABLE

By default, the table will be created in the public schema. To list all the tables in this schema, use the \dt command, specifying a template for schema and table names:

=> \dt public.*
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+---------
 public | t    | table | student
(1 row)

Page 8: Page 8

Tables (as well as other objects) can be moved between schemas. Since this is the logical level, the moving takes place only in the system catalog; physically, the data remains in place.

=> ALTER TABLE t SET SCHEMA special;
ALTER TABLE

What will be left in the public schema?

=> \dt public.*
Did not find any relation named "public.*".

Nothing. What about special?

=> \dt special.*
        List of relations
 Schema  | Name | Type  |  Owner
---------+------+-------+---------
 special | t    | table | student
(1 row)

The table has been moved. Now you can access it, as long as you specify the schema.

=> SELECT * FROM special.t;
 n
---
(0 rows)

Without the schema name, no table is found.

=> SELECT * FROM t;
ERROR:  relation "t" does not exist
LINE 1: SELECT * FROM t;
                      ^

Page 9: Search Path

Determining the object’s schema

  • schema is explicitly defined by a qualified name (schema.name)
  • name without a qualifier is looked up in the schemas specified in the
  • search path
  • Search path
    • defined by the search_path parameter,
    • the real value is shown by the current_schemas function
    • non-existent and inaccessible schemas are excluded
    • objects are created in the first schema explicitly specified in the path
    • pg_temp and pg_catalog schemas are implicitly included first
    • unless they are already specified in the search_path parameter

Speaker notes

When specifying an object, the schema it belongs to must be specified as well, since different schemas may contain objects with the same name.

If the object name is qualified by a schema, the explicitly specified schema is used. Otherwise, the schema is determined by the search_path configuration parameter. A search path is a list of schemas that is scanned sequentially from left to right, while excluding non-existent schemas and any schemas inaccessible to the current user.

When a new object is created with an unqualified name, the first viable schema from the search path is selected as the target schema for the object to be stored in. During object search, two schemas are implicitly added to the front of the search path:

● the pg_catalog schema for access to the system catalog,

● the pg_temp schema if the user has created any temporary objects.

The actual search path, including implicit schemas, can be obtained using the current_schemas(true) function.

The concept of search_path is similar to the PATH variable in operating systems.

https://postgrespro.com/docs/postgresql/16/runtime-config-client#GUC-SEA RCH-PATH

Page 10: Search Path

This is the default search path value:

=> SHOW search_path;
   search_path
-----------------
 "$user", public
(1 row)

“$user” stands for the schema with the same name as the current user (in our case, student). Since there is no student schema, it is ignored. Instead of guessing which schemas actually exist, which do not, and which are currently unavailable, you can use the function:

=> SELECT current_schemas(false);
 current_schemas
-----------------
 {public}
(1 row)

The logical parameter defines if the system schemas implicitly added to the path are displayed or not. Here, in addition to excluding the non-existent schema, PostgreSQL has implicitly included the system catalog schema at the top of the list:

=> SELECT current_schemas(true);
   current_schemas
---------------------
 {pg_catalog,public}
(1 row)

We can define the search path, for example:

=> SET search_path = public, special;
SET

The table will now be found.

=> SELECT * FROM t;
 n
---
(0 rows)

We have just set a configuration parameter at the session level. If we restart the session, it will have reverted to default. Setting the parameter at the cluster level is also ill-advised. Perhaps this path does not always work for everyone, and besides, different databases may have different sets of schemas. Thankfully, you can set the parameter for a specific database:

=> ALTER DATABASE data_logical SET search_path = public, special;
ALTER DATABASE

Now, it should apply for all connections to data_logical. Let’s try:

=> \c data_logical
You are now connected to database "data_logical" as user "student".
=> SHOW search_path;
   search_path
-----------------
 public, special
(1 row)

Page 11: System Catalog

Description of all cluster objects

  • the list of tables in each database (the pg_catalog schema) and several
  • cluster-wide objects
  • several quality-of-life views
  • Access
    • SQL queries, special psql commands
  • Conventions
    • table names start with pg_
    • column names contain a three-letter prefix
    • the column oid of the oid type is used as the primary key
    • object names are always lowercase

Speaker notes

The system catalog stores metadata about cluster objects. In each database, you can find a separate set of tables that describe the objects of this particular database, as well as several tables common to the whole cluster. There are several convenient views defined for these tables.

https://postgrespro.com/docs/postgresql/16/catalogs

You can access the system catalog using regular SQL queries. DDL commands can be used to modify data in the system catalog. In addition, psql offers a number of commands to browse the system catalog content.

https://postgrespro.com/docs/postgresql/16/app-psql

All system catalog table names start with pg_, for example, pg_database. Table columns start with a prefix, usually corresponding to the table name, for example, datname. Object names are always in lowercase, for example, postgres.

System catalog tables have primary keys. As a rule, these are columns with the name oid and of the oid type (object identifier, a 32-bit integer). These identifiers are also found in other columns as individual values or arrays, linking tables together logically. Foreign keys in the system catalog are not explicitly defined.

https://postgrespro.com/docs/postgresql/16/datatype-oid

Page 12: System Catalog

To display information about an object, psql (like other interactive user tools) accesses system catalog tables. For example, the \l command (list cluster databases) reads from pg_database table:

=> SELECT datname FROM pg_database;
   datname
--------------
 postgres
 student
 template1
 template0
 data_logical
(5 rows)

You can always check what queries a command performs:

=> \set ECHO_HIDDEN on
=> \l
********* QUERY **********
SELECT
  d.datname as "Name",
  pg_catalog.pg_get_userbyid(d.datdba) as "Owner",
  pg_catalog.pg_encoding_to_char(d.encoding) as "Encoding",
  CASE d.datlocprovider WHEN 'c' THEN 'libc' WHEN 'i' THEN 'icu' END AS "Locale Provider",
  d.datcollate as "Collate",
  d.datctype as "Ctype",
  d.daticulocale as "ICU Locale",
  d.daticurules as "ICU Rules",
  pg_catalog.array_to_string(d.datacl, E'\n') AS "Access privileges"
FROM pg_catalog.pg_database d
ORDER BY 1;
**************************
                                                         List of databases
     Name     |  Owner   | Encoding | Locale Provider |   Collate   |    Ctype    | ICU
Locale | ICU Rules |   Access privileges
--------------+----------+----------+-----------------+-------------+-------------+-------
-----+-----------+-----------------------
 data_logical | student  | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           |
 postgres     | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           |
 student      | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           |
 template0    | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           | =c/postgres          +
              |          |          |                 |             |             |
     |           | postgres=CTc/postgres
 template1    | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |
     |           | =c/postgres          +
              |          |          |                 |             |             |
     |           | postgres=CTc/postgres
(5 rows)

This is one way to explore the system catalog. Disable extended command output.

=> \set ECHO_HIDDEN off

The schema list is stored in the table:

=> SELECT nspname FROM pg_namespace;

Page 13: Page 13

      nspname
--------------------
 pg_toast
 pg_catalog
 public
 information_schema
 special
(5 rows)

You can get lists of objects such as tables or indexes like this:

=> SELECT relname, relkind, relnamespace FROM pg_class WHERE relname = 't';
 relname | relkind | relnamespace
---------+---------+--------------
 t       | r       |        16387
(1 row)

All columns here start with rel (relation). relkind — object type (r - table, i - index, etc.) relnamespace — schema The relnamespace field is of the oid type; here is the corresponding row in the pg_namespace table:

=> SELECT oid, nspname FROM pg_namespace WHERE oid = 16387;
  oid  | nspname
-------+---------
 16387 | special
(1 row)

To easily translate between text and oid formats, a special regnamespace type is used:

=> SELECT relname, relkind, relnamespace::regnamespace::text
FROM pg_class WHERE relname = 't';
 relname | relkind | relnamespace
---------+---------+--------------
 t       | r       | special
(1 row)

For example, let’s retrieve the list of objects from pg_catalog:

=> SELECT relname, relkind FROM pg_class
WHERE relnamespace = 'pg_catalog'::regnamespace LIMIT 5;
            relname             | relkind
--------------------------------+---------
 pg_statistic                   | r
 pg_type                        | r
 pg_foreign_table               | r
 pg_proc_oid_index              | i
 pg_proc_proname_args_nsp_index | i
(5 rows)

Similar reg types are defined for some other tables in the system catalog. They simplify queries and avoid unnecessary table joins.

Deleting Objects

Can you delete the special schema?

=> DROP SCHEMA special;
ERROR:  cannot drop schema special because other objects depend on it
DETAIL:  table t depends on schema special
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

A schema cannot be deleted while there are any objects in it. You need to delete or move them elsewhere first. Or you can delete a schema along with all its objects:

=> DROP SCHEMA special CASCADE;
NOTICE:  drop cascades to table t
DROP SCHEMA

Page 14: Page 14

What about deleting the entire database? Firstly, you cannot delete the database that you are currently connected to, so let’s disconnect.

=> \conninfo
You are connected to database "data_logical" as user "student" via socket in
"/var/run/postgresql" at port "5432".
=> \c postgres
You are now connected to database "postgres" as user "student".

Secondly, the database also cannot be deleted while there are any active connections to it. Let’s create a connection in a separate session and try to delete the database:

=> \c data_logical
You are now connected to database "data_logical" as user "student".
=> DROP DATABASE data_logical;
ERROR:  database "data_logical" is being accessed by other users
DETAIL:  There is 1 other session using the database.

An expected error. However, there is the FORCE option that will try to forcibly terminate all database connections, and then proceed to delete it:

=> DROP DATABASE data_logical WITH (FORCE);
DROP DATABASE

Page 15: Takeaways

On the logical level

  • a cluster contains databases
  • a database contains schemas
  • a schema contains specific objects (tables, indexes etc.)
  • New databases are created by cloning existing ones
  • Object schemas are determined by the search path
  • The system catalog stores a full description of the contents of the database cluster

Page 16: Practice

  1. In a new database, create a schema with the same name as the current user. Create a schema named app. Create several tables in both schemas. 2. Use psql to obtain descriptions of the schemas and a list of tables within them. 3. Modify the search path parameter so that when connecting to the database, tables from both schemas are accessible by an unqualified name. The “username” schema from task 1 should have priority. Verify that the new configuration works as intended.

Page 17: 1. Database, Schemas, Tables

Create a database:

=> CREATE DATABASE data_logical;
CREATE DATABASE
=> \c data_logical
You are now connected to database "data_logical" as user "student".

Schemas:

=> CREATE SCHEMA student;
CREATE SCHEMA
=> CREATE SCHEMA app;
CREATE SCHEMA

Tables for the student schema:

=> CREATE TABLE a(s text);
CREATE TABLE
=> INSERT INTO a VALUES ('student');
INSERT 0 1
=> CREATE TABLE b(s text);
CREATE TABLE
=> INSERT INTO b VALUES ('student');
INSERT 0 1

Tables for the app schema:

=> CREATE TABLE app.a(s text);
CREATE TABLE
=> INSERT INTO app.a VALUES ('app');
INSERT 0 1
=> CREATE TABLE app.c(s text);
CREATE TABLE
=> INSERT INTO app.c VALUES ('app');
INSERT 0 1

2. Description of Schemas and Tables

Description of schemas:

=> \dn
       List of schemas
  Name   |       Owner
---------+-------------------
 app     | student
 public  | pg_database_owner
 student | student
(3 rows)

Description of tables:

=> \dt student.*

Page 18: Page 18

        List of relations
 Schema  | Name | Type  |  Owner
---------+------+-------+---------
 student | a    | table | student
 student | b    | table | student
(2 rows)
=> \dt app.*
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+---------
 app    | a    | table | student
 app    | c    | table | student
(2 rows)

3. Search Path

The current search path settings show only the tables inside student:

=> SELECT * FROM a;
    s
---------
 student
(1 row)
=> SELECT * FROM b;
    s
---------
 student
(1 row)
=> SELECT * FROM c;
ERROR:  relation "c" does not exist
LINE 1: SELECT * FROM c;
                      ^

Change the search path at the database level.

=> ALTER DATABASE data_logical SET search_path = "$user",app,public;
ALTER DATABASE
=> \c
You are now connected to database "data_logical" as user "student".
=> SHOW search_path;
     search_path
----------------------
 "$user", app, public
(1 row)

Tables from both schemas are visible now, with student taking precedence:

=> SELECT * FROM a;
    s
---------
 student
(1 row)
=> SELECT * FROM b;
    s
---------
 student
(1 row)
=> SELECT * FROM c;

Page 19: Page 19

  s
-----
 app
(1 row)
=> select usename, application_name from pg_stat_activity where datname = 'data_logical';
 usename | application_name
---------+------------------
 student | psql
(1 row)
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE data_logical;
DROP DATABASE

06 Lesson 06: Data Organization / Physical Structure

Native Markdown source page: Data Organization / Physical Structure
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_06_data_physical.html Archive: DEV1-handouts-16-en.zip PDF pages: 15

Page 1: Data Organization / Physical Structure

© Postgres Professional, 2017–2025 Authors Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of course materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Tablespaces and Catalogs
  • Files and Pages
  • Forks: Main, Visibility Map, Free Space Map
  • TOAST

Page 3: Tablespaces

Source slide page 3: Tablespaces{#source-figure-003 caption=“Source slide page 3: Tablespaces”}

Speaker notes

Tablespaces are used to organize the physical storage of data and determine the location of data in the file system. For example, one tablespace can be mapped to slow disks for archived data, and another on fast disks with frequent activity.

On cluster initialization, two tablespaces are created: pg_default and pg_global.

A tablespace can be used by multiple databases, and a database can use multiple tablespaces at once.

Each database has a default tablespace where all database objects are created (unless explicitly set otherwise). System catalog objects are also stored there. Databases will use the pg_default tablespace as their default, unless another one is set by the user.

The pg_global tablespace is special as it stores only those objects that are shared by the whole cluster.

Page 4: Catalogs

Source slide page 4: Catalogs{#source-figure-004 caption=“Source slide page 4: Catalogs”}

PGDATA/global/
...
...
...
PGDATA/base/dboid/
...
NNN
NNN.1
NNN.2
...
PGDATA/pg_tblspc/tsoid
/path-to-catalog/ver/dboid/
...
...
...

Speaker notes

Essentially, a tablespace is a reference to the directory in which the data is located. The standard tablespaces pg_global and pg_default are always located in PGDATA/global/ and PGDATA/base/, respectively. When a custom tablespace is created, an arbitrary directory can be specified. For (internal) convenience, PostgreSQL also creates a symbolic link to the directory in PGDATA/pg_tblspc/.

The PGDATA/base/ directory comprises different subdirectories for each database (unlike PGDATA/global/, which stores data referring to the whole cluster).

Inside a custom tablespace directory, there is another level of subdirectories for different PostgreSQL server versions. This is helpful during server version upgrade.

Finally, these directories are where the actual objects are stored, one or more files per object.

Each such file, called a segment, takes up no more than 1GB by default (this size can be changed when building the server). This is also why several files can correspond to each object. It is necessary to take into account the impact of a potentially large number of files on the file system used.

https://postgrespro.com/docs/postgresql/16/storage-file-layout

Page 5: Using Tablespaces

Initially, a cluster contains two tablespaces. You can learn about them from the system catalog:

=> SELECT spcname FROM pg_tablespace;
  spcname
------------
 pg_default
 pg_global
(2 rows)

Naturally, this is one of the cluster’s global tables. A command in psql that does a similar thing:

=> \db
       List of tablespaces
    Name    |  Owner   | Location
------------+----------+----------
 pg_default | postgres |
 pg_global  | postgres |
(2 rows)

A new tablespace needs an empty directory owned by the same OS user who has started the database server:

=> \! sudo mkdir /var/lib/postgresql/ts_dir

Change the directory owner:

=> \! sudo chown postgres /var/lib/postgresql/ts_dir

The tablespace can be created now:

=> CREATE TABLESPACE ts LOCATION '/var/lib/postgresql/ts_dir';
CREATE TABLESPACE
=> \db
                List of tablespaces
    Name    |  Owner   |          Location
------------+----------+----------------------------
 pg_default | postgres |
 pg_global  | postgres |
 ts         | student  | /var/lib/postgresql/ts_dir
(3 rows)

When creating a database, you can set a tablespace to be used by default:

=> CREATE DATABASE data_physical TABLESPACE ts;
CREATE DATABASE
=> \c data_physical
You are now connected to database "data_physical" as user "student".

All new database objects will be created in this tablespace by default.

=> CREATE TABLE t(id integer PRIMARY KEY, s text);
CREATE TABLE
=> INSERT INTO t(id, s)
    SELECT id, id::text FROM generate_series(1,100_000) id;
INSERT 0 100000

Page 6: Forks and Files

Source slide page 6: Forks and Files{#source-figure-006 caption=“Source slide page 6: Forks and Files”}

Main

  • the actual data
  • Free space map
    • in pages after
    • vacuuming
  • Visibility map
    • marks pages where
    • all row versions
    • are visible
    • in all snapshots

Speaker notes

Usually, each object has several corresponding forks. Each fork is a set of segments (that is, one or more files). The segment files that make up the forks are logically divided into pages, usually 8KB each (this size can be set for the entire cluster only when building the server). The file pages of all forks are read from disk in a completely uniform way through a shared buffer cache mechanism. The forks themselves contain different information and have different internal page structure.

The main fork contains the actual data, such as table row versions and index records.

The vm fork is the visibility (bit)map. It marks pages containing only the current row versions visible in all data snapshots. In other words, these pages have not been changed for so long that they have been completely vacuumed of obsolete tuples.

The visibility map is used to optimize vacuuming (marked pages do not need to be vacuumed) and to speed up index access. Row versioning information exists only for tables, but not for indexes. Therefore, after getting a link to the required row version from an index, you first need to check its visibility by reading the corresponding table page. But if the index itself already contains all the necessary columns, and the page is marked in the visibility map, then reading the table page can be skipped.

The fsm fork is the free space map. It marks the available space inside the pages, which is created, for example, during vacuuming. This map is used when inserting new row versions in order to quickly find the appropriate page.

Page 7: Forks and Files

Run vacuum to ensure that all forks exist in the new table:

=> VACUUM t;
VACUUM

You can check where an object’s files are stored:

=> SELECT pg_relation_filepath('t');
            pg_relation_filepath
---------------------------------------------
 pg_tblspc/16386/PG_16_202307071/16387/16388
(1 row)

Let’s check the files (names and sizes in bytes):

student$ sudo bash -c 'cd /var/lib/postgresql/16/main/pg_tblspc/16386/PG_16_202307071/16387; ls -l
16388*'
-rw------- 1 postgres postgres 4423680 Nov 27 14:19 16388
-rw------- 1 postgres postgres   24576 Nov 27 14:19 16388_fsm
-rw------- 1 postgres postgres    8192 Nov 27 14:19 16388_vm

The files represent the three forks: main, free space map, and visibility map. Objects can be moved between tablespaces, but (unlike with schemas) this involves physically moving the data around.

=> ALTER TABLE t SET TABLESPACE pg_default;
ALTER TABLE
=> SELECT pg_relation_filepath('t');
 pg_relation_filepath
----------------------
 base/16387/16395
(1 row)

Size of Objects

There are several ways to find the size of a database and objects in it.

=> SELECT pg_database_size('data_physical');
 pg_database_size
------------------
         14422499
(1 row)

Prettify the number for readability:

=> SELECT pg_size_pretty(pg_database_size('data_physical'));
 pg_size_pretty
----------------
 14 MB
(1 row)

Total table size (with all indexes):

=> SELECT pg_size_pretty(pg_total_relation_size('t'));
 pg_size_pretty
----------------
 6568 kB
(1 row)

Table size (separately):

=> SELECT pg_size_pretty(pg_table_size('t'));

Page 8: Page 8

 pg_size_pretty
----------------
 4360 kB
(1 row)

Index size (separately):

=> SELECT pg_size_pretty(pg_indexes_size('t'));
 pg_size_pretty
----------------
 2208 kB
(1 row)

You can get the size of each individual fork, for example:

=> SELECT pg_size_pretty(pg_relation_size('t','main'));
 pg_size_pretty
----------------
 4320 kB
(1 row)

Another function shows the size of a tablespace on the disk:

=> SELECT pg_size_pretty(pg_tablespace_size('ts'));
 pg_size_pretty
----------------
 9740 kB
(1 row)

Page 9: TOAST

A row version must fit into one page

  • some of the attributes can be compressed
  • or moved into a separate TOAST table
  • or both compressed and moved
  • TOAST table
    • pg_toast schema
    • supported by its own index
    • oversized attributes are divided into parts smaller than a page
    • accessed by querying a corresponding oversized attribute
    • native versioning
    • works transparently for the application

Speaker notes

Any row version in PostgreSQL must fit entirely into one page. Oversized row versions are stored using TOAST, The Oversized Attributes Storage Technique. It implies several strategies. Firstly, the oversized attribute can be compressed so that the row version fits into the page. If this fails, the attribute can be moved into a separate service table. Both approaches can be applied at the same time.

Any primary table can have a separate TOAST table (with a dedicated index) created for it, if necessary. Such tables and indexes are located in a separate pg_toast schema and therefore are usually hidden.

The row versions in the TOAST table must also fit into one page each, so longer values are split into multiple chunks and transparently “glued” together by PostgreSQL when the application demands.

TOAST tables are used only when oversized values are accessed. The tables have their own row versioning. Whenever a data update in the main table does not modify the oversized value in the TOAST table, the new row version in the main table will refer to the same old TOAST value, saving disk space.

https://postgrespro.com/docs/postgresql/16/storage-toast

Page 10: TOAST

Add a very long string to the table:

=> INSERT INTO t(id, s)
SELECT 0, string_agg(id::text,'.') FROM generate_series(1,5000) AS id;
INSERT 0 1

Will the table size change?

=> SELECT pg_size_pretty(pg_table_size('t'));
 pg_size_pretty
----------------
 4416 kB
(1 row)

Yes. What about the main fork size?

=> SELECT pg_size_pretty(pg_relation_size('t','main'));
 pg_size_pretty
----------------
 4320 kB
(1 row)

No. Since the tuple won’t fit into a single page, the value of s will be sliced and put into a toast table. You can find this table in the system catalog (using the regclass type to get the table name from its oid):

=> SELECT oid, reltoastrelid::regclass::text FROM pg_class WHERE relname='t';
  oid  |      reltoastrelid
-------+-------------------------
 16388 | pg_toast.pg_toast_16388
(1 row)

The character string is sliced, and PostgreSQL will reassemble it as needed:

=> SELECT chunk_id, chunk_seq, left(chunk_data::text,45) AS chuck_data
FROM pg_toast.pg_toast_16388;
 chunk_id | chunk_seq |                  chuck_data
----------+-----------+-----------------------------------------------
    16398 |         0 | \x545d000000312e322e332e342e00352e362e372e382
    16398 |         1 | \x392e353161ff31002e3531322e353133002e3531342
    16398 |         2 | \xe215e216e217e218abe219e11a30e11b30e11c30e11
    16398 |         3 | \x11f4aa3611f43611f43611f43611f4aa3611f43611f
    16398 |         4 | \xf43211f4325511f43211f43211f43211f4325511f43
    16398 |         5 | \xf43811f43811f43811f4385511f43811f43811f4381
    16398 |         6 | \x11f4aa3411f43411f43411f43411f4aa3411f43411f
    16398 |         7 | \x0132370132aa370132370132370132370132aa38013
    16398 |         8 | \xf43611f43611f43611f4365511f43611f43611f4361
(9 rows)

Finally, remove the database.

=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE data_physical;
DROP DATABASE

Since there are no objects left in the tablespace, it too can be dropped:

=> DROP TABLESPACE ts;
DROP TABLESPACE
=> \! sudo rmdir /var/lib/postgresql/ts_dir

Page 11: Page 11

Page 12: Takeaways

On the physical level

  • data is distributed across tablespaces (directories)
  • an object comprises several forks
  • a fork consists of one or more segments
  • Tablespaces are managed by the administrator
  • Forks, files, and TOAST are all PostgreSQL internals

Page 13: Practice

  1. Create a new database and connect to it. Create a tablespace ts. Create a table t in the ts tablespace and add several rows to it. 2. Calculate the space occupied by the database, table, and tablespaces ts and pg_default. 3. Move the table to the pg_default tablespace. How has the space used by tablespaces changed? 4. Delete the tablespace ts.

Page 14: 1. Tablespaces and Tables

Create a database:

=> CREATE DATABASE data_physical;
CREATE DATABASE
=> \c data_physical
You are now connected to database "data_physical" as user "student".

Tablespace:

student$ sudo mkdir /var/lib/postgresql/ts_dir
student$ sudo chown postgres /var/lib/postgresql/ts_dir
=> CREATE TABLESPACE ts LOCATION '/var/lib/postgresql/ts_dir';
CREATE TABLESPACE

Create a table:

=> CREATE TABLE t(n integer) TABLESPACE ts;
CREATE TABLE
=> INSERT INTO t SELECT 1 FROM generate_series(1,1000);
INSERT 0 1000

2. Data Size

Database size:

=> SELECT pg_size_pretty(pg_database_size('data_physical')) AS db_size;
 db_size
---------
 7580 kB
(1 row)

Table size:

=> SELECT pg_size_pretty(pg_total_relation_size('t')) AS t_size;
 t_size
--------
 64 kB
(1 row)

Tablespace size:

=> SELECT
    pg_size_pretty(pg_tablespace_size('pg_default')) AS pg_default_size,
    pg_size_pretty(pg_tablespace_size('ts')) AS ts_size;
 pg_default_size | ts_size
-----------------+---------
 36 MB           | 68 kB
(1 row)

The size of the tablespace is larger than the size of the table by 4KB due to how Linux calculates the size of the directory contents.

3. Moving a Table

Move the table:

=> ALTER TABLE t SET TABLESPACE pg_default;
ALTER TABLE

New size of tablespaces:

Page 15: 4. Removing a Tablespace

=> SELECT
    pg_size_pretty(pg_tablespace_size('pg_default')) AS pg_default_size,
    pg_size_pretty(pg_tablespace_size('ts')) AS ts_size;
 pg_default_size |  ts_size
-----------------+------------
 36 MB           | 4096 bytes
(1 row)

Remove the tablespace…

=> DROP TABLESPACE ts;
DROP TABLESPACE

…and the directory where its data was stored:

student$ sudo rm -rf /var/lib/postgresql/ts_dir
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE data_physical;
DROP DATABASE

07 Lesson 07: The Bookstore App / Data Schema and API

Native Markdown source page: The Bookstore App / Data Schema and API
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_07_bstore_schema.html Archive: DEV1-handouts-16-en.zip PDF pages: 29

Page 1: The Bookstore App / Data Schema and API

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Bookstore App Overview
  • Data Schema Design, Normalization
  • Data Schema, the Final Version
  • Setting Up the API

Page 3: The Application

Source slide page 3: The Application{#source-figure-003 caption=“Source slide page 3: The Application”}

Speaker notes

The application consists of several parts, which are provided as separate tabs.

“Store” is a customer UI for buying books online.

Other tabs represent the employee UI, which is available only to the bookstore staff (the admin panel).

“Catalog” is the storekeeper’s UI which is used for ordering books to the store and viewing arrivals and sales.

“Books” and “Authors” are the UI for librarians, where they can register arrivals.

For training purposes, all this functionality is exposed in a single web page. If any feature is unavailable because the required object (such as a table or a function) is missing, the application will report an error. It also displays the text of all queries sent to the server.

We will start with an empty database and will gradually implement all the required components by the end of the course.

The source code of the application frontend will not be discussed in this course, but is available for download: https://pubgit.postgrespro.ru/pub/dev1app.git

Page 4: Application Demonstration

This demo shows the Bookstore app as it would look like after completing all practice assignments. The app opens in a separate browser tab in the course VM: Opening http://localhost…

student$ xdg-open http://localhost

Page 5: Books

An ER model for high-level design

  • entities — concepts of the application domain
  • relationships — connections between entities
  • attributes — properties of entities and relationships
  • entity

Book

  • attributes
  • title
  • authors
  • quantity
  • operations

Speaker notes

After taking a look at the application’s UI and functionality, we need to figure out its data schema. We will not go into details about database design: it is a separate branch of knowledge, which is beyond the scope of this course, but we cannot ignore this topic entirely.

High-level database design often uses the ER model (“Entity–Relationship”). It deals with entities (concepts of the subject area), their relationships, and attributes (the properties of entities and relationships). The model allows us to remain at the logical level, without getting down to data representation at the physical level (such as its table form).

The first approach to database design is creating a diagram as shown on this slide: a book is represented as a single big entity, and everything else becomes its attributes.

Page 6: Data Schema

Source slide page 6: Data Schema{#source-figure-006 caption=“Source slide page 6: Data Schema”}

 id |       title       |            author            | qty | operation
 −−−+−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−+−−−−−+−−−−−−−−−−−
  1 | The Tempest       | William Shakespeare          | 10  | +11
  1 | THE TEMPEST       | William Shakespeare          | 10  | -1
  2 | Romeo and Juliet  | William Shakespeare          | 4   | +4
  3 | Good Omens        | Terry Pratchett              | 7   | +7
  3 | Good Omens        | Neil Gaiman                  | 7   | 0
  • Some data is duplicated
    • hard to maintain consistency
    • hard to perform updates
    • hard to write queries

Speaker notes

Clearly, this approach cannot be correct. It may be not quite obvious in the diagram itself, but let’s try to map this diagram onto database tables. There are several ways to do it. One of them is shown on the slide: the table corresponds to the entity, and table columns represent the attributes of this entity.

This diagram is a good illustration that some data ends up duplicated (as highlighted on the slide). Data duplication may lead to problems with consistency, the very thing a database system is supposed to ensure.

For example, each of the two rows related to book 3 must list the total quantity (7 items). How should purchases be recorded, then? Some rows will need to be added to record the purchase operations. And then the quantity in all the duplicated rows will need to be reduced from 7 to 6. But what if an error leads to data discrepancy between these rows? How can we define a constraint to make sure the values stay in sync?

Many queries will also become overcomplicated. How can we find the total number of books? Or get a list of distinct authors?

Thus, such a schema will not work well for relational databases.

Page 7: Data Schema (Variant)

entity | attribute | value
−−−−−−−+−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−
     1 | title     | The Tempest
     1 | author    | William Shakespeare
     1 | qty       | 10
     1 | operation | +11
     1 | operation | -1
     2 | title     | Romeo and Juliet
     2 | author    | William Shakespeare
     2 | qty       | 4
     2 | operation | +4
   ... | ...       | ...
  • Schemaless design
    • consistency is maintained at the application side
    • hard to write queries
    • low performance (multiple joins)

Speaker notes

Another way to represent an entity as a table is a so-called EAV schema (“entity–attribute–value”). It allows storing anything at all in a single table. Technically, we get a relational database, but it has virtually no schema, and the database system cannot guarantee data consistency. Consistency has to be maintained by the application alone, and sooner or later it is bound to be compromised.

With such a schema, it is hard to write queries (although they are quite easy to generate). As a result, handling any significant volume of data becomes a problem because of multiple self-joins.

However, this approach has some advantages. For example, such a “schema” is easy to build up: when adding a new entity or attribute, you simply add new rows to the table.

Still, in general, this is not a good approach.

Page 8: Data Schema (Variant)

 book_id | description
−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
       1 | {"title": "The Tempest",
         |  "authors": [ "William Shakespeare" ],
         |  "qty": 10,
         |  "operations": [ +11, -1 ]}
       3 | {"title": "Good Omens",
         |  "authors": [ "Terry Pratchett",
         |               "Neil Gaiman" ],
         |  "qty": 7,
         |  "operations": [ +7 ]}
     ... | ...
  • Schemaless design
    • consistency is maintained at the application side
    • hard to write queries (a dedicated language)
    • has index support

Speaker notes

Another similar approach involves representing data in JSON format, NoSQL-style. All the previous considerations still apply here.

Besides, you can’t query such a structure using SQL. Instead, a special language is used. (Previously, jsQuery was the usual choice, until the SQL:2016 standard brought SQL/JSON Path Language capabilities into PostgreSQL 16.)

https://github.com/postgrespro/jsquery

https://postgrespro.com/docs/postgresql/16/functions-json#FUNCTIONS-SQ LJSON-PATH

Although PostgreSQL provides JSON index support, performance is still a concern.

Such a schema is best used when the database only needs to get JSON data by ID, and no serious data alteration within the JSON structure is expected. For us, this is not the case.

(Naturally, nothing is set in stone here. See the last practice assignment for details on this.)

Page 9: Books and Operations

Source slide page 9: Books and Operations{#source-figure-009 caption=“Source slide page 9: Books and Operations”}

Normalization reduces data redundancy

  • Large entities are split into smaller ones

Book

Operation

Speaker notes

To work with data in a relational database system properly, we need to eliminate redundancy. This process is called normalization.

You might be familiar with various normal form concepts (first, second, third, Boyce–Codd, etc.) We are not going to discuss them here; in essence, it is enough to understand that all this math pursues one and the same goal: eliminating redundancy.

The way to reduce redundancy is to split a larger entity into smaller ones. How exactly to split it should be prompted by common sense (which cannot be replaced by the knowledge of normal forms alone anyway).

In our case, everything is quite straightforward. Let’s start by separating books and operations. These two entities are connected by a one-to-many relationship: there can be several operations on each book, but each operation relates to only one book.

Page 10: Data Schema

books
 book_id |       title       |           author
−−−−−−−−−+−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
       1 | The Tempest       | William Shakespeare
       2 | Romeo and Juliet  | William Shakespeare
       3 | Good Omens        | Terry Pratchett
       3 | Good Omens        | Neil Gaiman
operations
 operation_id | book_id | qty_change | date_created
−−−−−−−−−−−−−−+−−−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−
            1 |       1 |        +10 | 2020-07-13
            2 |       1 |         -1 | 2020-08-25
            3 |       3 |         +7 | 2020-07-13
            4 |       2 |         +4 | 2020-07-13

Speaker notes

At the physical level, this can be represented by two tables: books and operations.

An operation changes the quantity of books (sell books if negative, order new books if positive). Note that the book entity has no quantity attribute anymore. Instead, you get the quantity by adding up all changes made by operations related to this book. Having an additional quantity attribute would only create data redundancy again.

This solution might raise some eyebrows at first. Is it really a good idea to have to calculate the amount every time instead of having a separate field to query? The answer is that we can simply create a view that shows the current amount of books in store. This will not lead to redundancy, because the view is just another query.

But what about performance? If summing up all changes brings too much overhead, we can resort to denormalization: add the quantity field to the books table and ensure its consistency with the operations table. Whether to do this or not is beyond the scope of this course (it is discussed in the QPT Query Performance Tuning course). Common sense suggests that it’s not required for our bare-bones app, but we will get back to denormalization when we get to the Triggers lecture.

Thus, moving all operations into a separate entity resolves most of the duplication issues, but not all of them.

Page 11: Books, Authors, Operations

Source slide page 11: Books, Authors, Operations{#source-figure-011 caption=“Source slide page 11: Books, Authors, Operations”}

Book

Operation

Speaker notes

That’s why we have to go deeper: separate books from authors and tie them by a many-to-many relationship: a book can be written by several authors, and each author can have more than one book. At the table level, such relationship can be implemented using an additional intermediate table.

The author’s attributes will be their first, last and middle name. It makes sense because we may need to work with each of these attributes separately, for example, to display the author’s last name and initials.

Page 12: Application Schema

student$ psql bookstore

The application schema consists of four tables:

=> \dt
            List of relations
  Schema   |    Name    | Type  |  Owner
-----------+------------+-------+---------
 bookstore | authors    | table | student
 bookstore | authorship | table | student
 bookstore | books      | table | student
 bookstore | operations | table | student
(4 rows)

Books

=> \d books
                         Table "bookstore.books"
 Column  |  Type   | Collation | Nullable |           Default
---------+---------+-----------+----------+------------------------------
 book_id | integer |           | not null | generated always as identity
 title   | text    |           | not null |
Indexes:
    "books_pkey" PRIMARY KEY, btree (book_id)
Referenced by:
    TABLE "authorship" CONSTRAINT "authorship_book_id_fkey" FOREIGN KEY (book_id)
REFERENCES books(book_id)
    TABLE "operations" CONSTRAINT "operations_book_id_fkey" FOREIGN KEY (book_id)
REFERENCES books(book_id)

We use the following data types here: integer; text, which is a text string of arbitrary length. We also use the PRIMARY KEY constraint. The GENERATED AS IDENTITY clause is used to automatically generate unique values. GENERATED AS IDENTITY columns take their values from special database objects called sequences. We can obtain the name of the used sequence as follows:

=> SELECT pg_get_serial_sequence('books','book_id');
   pg_get_serial_sequence
-----------------------------
 bookstore.books_book_id_seq
(1 row)

If required, you can also create sequences manually and query them directly:

=> SELECT nextval('books_book_id_seq');
 nextval
---------
       8
(1 row)

A sequence is the most efficient way of generating unique IDs. But you should keep in mind that: there may be gaps in numbering (since the changes are not transactional); the numbers may not increase monotonically (if sessions cache values). Here is the data stored in the books table:

=> SELECT * FROM books \gx

Page 13: Page 13

-[ RECORD 1
]-----------------------------------------------------------------------------------------
---------------------------------------------
book_id | 1
title   | The Tale of Tsar Saltan
-[ RECORD 2
]-----------------------------------------------------------------------------------------
---------------------------------------------
book_id | 2
title   | Romeo and Juliet
-[ RECORD 3
]-----------------------------------------------------------------------------------------
---------------------------------------------
book_id | 3
title   | Good Omens
-[ RECORD 4
]-----------------------------------------------------------------------------------------
---------------------------------------------
book_id | 4
title   | Dark Avenues
-[ RECORD 5
]-----------------------------------------------------------------------------------------
---------------------------------------------
book_id | 5
title   | Travels into Several Remote Nations of the World. In Four Parts. By Lemuel
Gulliver, First a Surgeon, and then a Captain of Several Ships
-[ RECORD 6
]-----------------------------------------------------------------------------------------
---------------------------------------------
book_id | 6
title   | Three Men in a Boat (To Say Nothing of the Dog)
-[ RECORD 7
]-----------------------------------------------------------------------------------------
---------------------------------------------
book_id | 7
title   | 101 Famous Poems
Note

Note Note that book titles can be quite long. We use psql expanded display mode, and xheader_width parameter introduced in PostgreSQL 16 helps us cut out the header of the wide column:

=> \pset xheader_width 40
Expanded header width is 40.

Compare the output:

=> SELECT * FROM books \gx
-[ RECORD 1 ]---------------------------
book_id | 1
title   | The Tale of Tsar Saltan
-[ RECORD 2 ]---------------------------
book_id | 2
title   | Romeo and Juliet
-[ RECORD 3 ]---------------------------
book_id | 3
title   | Good Omens
-[ RECORD 4 ]---------------------------
book_id | 4
title   | Dark Avenues
-[ RECORD 5 ]---------------------------
book_id | 5
title   | Travels into Several Remote Nations of the World. In Four Parts. By Lemuel
Gulliver, First a Surgeon, and then a Captain of Several Ships
-[ RECORD 6 ]---------------------------
book_id | 6
title   | Three Men in a Boat (To Say Nothing of the Dog)
-[ RECORD 7 ]---------------------------
book_id | 7
title   | 101 Famous Poems

Authors

=> \d authors

Page 14: Page 14

                          Table "bookstore.authors"
   Column    |  Type   | Collation | Nullable |           Default
-------------+---------+-----------+----------+------------------------------
 author_id   | integer |           | not null | generated always as identity
 last_name   | text    |           | not null |
 first_name  | text    |           | not null |
 middle_name | text    |           |          |
Indexes:
    "authors_pkey" PRIMARY KEY, btree (author_id)
Referenced by:
    TABLE "authorship" CONSTRAINT "authorship_author_id_fkey" FOREIGN KEY (author_id)
REFERENCES authors(author_id)

In this table, we also use the NOT NULL constraint, which means that undefined values are not allowed.

=> SELECT * FROM authors;
 author_id |  last_name  | first_name | middle_name
-----------+-------------+------------+--------------
         1 | Pushkin     | Alexander  | Sergeyevich
         2 | Shakespeare | William    |
         3 | Pratchett   | Terry      |
         4 | Gaiman      | Neil       |
         5 | Bunin       | Ivan       | Alekseyevich
         6 | Swift       | Jonathan   |
         7 | Jerome      | Jerome     | Klapka
(7 rows)
Note

Note Note that the middle name might be missing (or defined by an empty string). The PRIMARY KEY constraint was mentioned in the \d output together with the terms “index” and “btree”. Btree is the main index type used in databases to speed up search and provide support for constraints (primary key and unique). Suppose that our bookstore sells books written by a million of different authors with the same name:

=> BEGIN; -- let’s explicitly start a transaction to roll back the changes later
BEGIN
=> INSERT INTO authors(first_name, last_name)
    SELECT 'John', 'Wordsmith' FROM generate_series(1, 1_000_000);
INSERT 0 1000000

How long will it take to find an author in such a table?

=> \timing on
Timing is on.
=> SELECT * FROM authors WHERE last_name = 'Pushkin';
 author_id | last_name | first_name | middle_name
-----------+-----------+------------+-------------
         1 | Pushkin   | Alexander  | Sergeyevich
(1 row)
Time: 229.911 ms
=> \timing off
Timing is off.

If we ask the optimizer to display the query plan, we will see that Seq Scan is used; it means that the whole table is scanned sequentially using a Filter to find the required value:

=> EXPLAIN (costs off)
SELECT * FROM authors WHERE last_name = 'Pushkin';
               QUERY PLAN
-----------------------------------------
 Seq Scan on authors
   Filter: (last_name = 'Pushkin'::text)
(2 rows)

And what if we perform the search by an indexed field?

=> \timing on
Timing is on.

Page 15: Page 15

=> SELECT * FROM authors WHERE author_id = 1;
 author_id | last_name | first_name | middle_name
-----------+-----------+------------+-------------
         1 | Pushkin   | Alexander  | Sergeyevich
(1 row)
Time: 1.452 ms
=> \timing off
Timing is off.

The query time has been reduced by several orders of magnitude. And the query plan now contains an index:

=> EXPLAIN (costs off)
SELECT * FROM authors WHERE author_id = 1;
                QUERY PLAN
------------------------------------------
 Index Scan using authors_pkey on authors
   Index Cond: (author_id = 1)
(2 rows)

We can also create an index by the last name (and analyze the table to gather up-to-date statistics):

=> ANALYZE authors;
ANALYZE
=> CREATE INDEX ON authors(last_name);
CREATE INDEX
=> EXPLAIN (costs off)
SELECT * FROM authors WHERE last_name = 'Pushkin';
                    QUERY PLAN
---------------------------------------------------
 Index Scan using authors_last_name_idx on authors
   Index Cond: (last_name = 'Pushkin'::text)
(2 rows)

However, the index is not a universal performance tuning tool. Having an index is usually very useful if the query needs to select only a small portion of all table rows. But if it is required to read a lot of data, the index will only add overhead, and the optimizer is smart enough to understand it:

=> EXPLAIN (costs off)
SELECT * FROM authors WHERE last_name = 'Wordsmith';
                QUERY PLAN
-------------------------------------------
 Seq Scan on authors
   Filter: (last_name = 'Wordsmith'::text)
(2 rows)

Besides, you have to keep in mind that indexes take extra disk space, and index updates caused by table modifications bring extra overhead. Let’s cancel all our changes (including index creation):

=> ROLLBACK;
ROLLBACK
=> ANALYZE authors;
ANALYZE

Authorship

This table implements many-to-many relationship.

=> \d authorship

Page 16: Page 16

             Table "bookstore.authorship"
  Column   |  Type   | Collation | Nullable | Default
-----------+---------+-----------+----------+---------
 book_id   | integer |           | not null |
 author_id | integer |           | not null |
 seq_num   | integer |           | not null |
Indexes:
    "authorship_pkey" PRIMARY KEY, btree (book_id, author_id)
Foreign-key constraints:
    "authorship_author_id_fkey" FOREIGN KEY (author_id) REFERENCES authors(author_id)
    "authorship_book_id_fkey" FOREIGN KEY (book_id) REFERENCES books(book_id)

In addition to all the previously used constraints, this table also uses FOREIGN KEY, which is a referential integrity constraint. In fact, this table contains two foreign keys: one of them refers to the books table, and the other refers to the authors table. The seq_num column defines the order in which multiple authors of the same book should be listed.

Note

Note Note that we have a composite primary key here.

=> SELECT * FROM authorship;
 book_id | author_id | seq_num
---------+-----------+---------
       1 |         1 |       1
       2 |         2 |       1
       3 |         3 |       2
       3 |         4 |       1
       4 |         5 |       1
       5 |         6 |       1
       6 |         7 |       1
       7 |         1 |       1
       7 |         5 |       2
       7 |         2 |       3
(10 rows)

Operations

=> \d operations
                         Table "bookstore.operations"
    Column    |  Type   | Collation | Nullable |           Default
--------------+---------+-----------+----------+------------------------------
 operation_id | integer |           | not null | generated always as identity
 book_id      | integer |           | not null |
 qty_change   | integer |           | not null |
 date_created | date    |           | not null | CURRENT_DATE
Indexes:
    "operations_pkey" PRIMARY KEY, btree (operation_id)
Foreign-key constraints:
    "operations_book_id_fkey" FOREIGN KEY (book_id) REFERENCES books(book_id)

This table uses one more data type: date, which defines a date without time of day. For the date_created column, the current date is specified as the default value (using the DEFAULT clause).

=> SELECT * FROM operations;
 operation_id | book_id | qty_change | date_created
--------------+---------+------------+--------------
            1 |       1 |         10 | 2025-11-27
            2 |       1 |         10 | 2025-11-27
            3 |       1 |         -1 | 2025-11-27
(3 rows)

Apart from the data types used in application tables, we are going to come across the boolean type all the time. For example, the expressions in WHERE clauses are of the boolean type. It’s important to remember that, unlike traditional programming languages, SQL uses three-valued logic: in addition to true and false, there is also the NULL value (which can be interpreted as “the value is unknown”). You will see some other data types in the examples. Check the Basic Data Types and Functions handout (datatypes.pdf) for details. We will also cover some other types that are more complex: the composite type, which represents a record similar to a table row (in SQL. Composite Types);

Page 17: Page 17

arrays (in PL/pgSQL. Arrays).

Page 18: Designing the API

Tables and triggers

  • reading data directly from tables (views)
  • writing data directly to tables (views)
  • using triggers for changing related tables
  • the application must be aware of the data model
  • this approach provides maximum flexibility
  • hard to maintain consistency
  • Functions
    • reading data via table functions
    • writing data by calling functions
    • the application is separated from the data model and is limited by API
    • lots of wrapper functions required
    • potential performance issues

Speaker notes

There are several ways to set up an API.

The first option is to allow the application to access and modify database tables directly. In this case, the application must have the exact knowledge of the data model. This requirement can be relaxed to some extent by using views.

Another limitation of this approach is that the application has to follow certain rules; otherwise, it is very hard to maintain data consistency if you have to address all possible inappropriate operations at the database level. But this is the approach that provides the most flexibility.

Another option is to forbid direct table access from the application and allow only function calls. Reading data can be performed by table functions (which return a set of rows). Writing can be performed by calling other functions and passing the required data to them. In this case, all the necessary consistency checks can be implemented within functions: the database will be protected, but the application will be able to use only a limited set of features that we provide. It requires writing many wrapper functions and can lead to performance degradation.

You can also combine these two approaches. For example, you can allow the application to read data from tables directly, but perform modifications only by functions.

Page 19: Customer API

Source slide page 19: Customer API{#source-figure-019 caption=“Source slide page 19: Customer API”}

Speaker notes

In this application, we will try different ways of setting up the interface (although it’s usually better to stick to one approach when developing real applications).

The store will use API functions:

● get_catalog for looking up books (see SQL. Composite Types lesson)

● buy_book for making a purchase (see PL/pgSQL. Query Execution)

Page 20: Employee API

Source slide page 20: Employee API{#source-figure-020 caption=“Source slide page 20: Employee API”}

Speaker notes

The admin panel is going to retrieve data by accessing the following views (which we create as part of the practice for this lecture):

● catalog_v for the list of books,

● authors_v for the list of authors,

● operations_v for the list of operations.

Authors will be added using the add_author function (we will create it once we get to the PL/pgSQL. Query Execution lecture). For adding books, we will implement the add_book function (PL/pgSQL. Arrays lecture).

To enable book purchase, we will make the catalog_v view updatable (PL/pgSQL. Triggers lecture).

Page 21: Views

A view is a named query. For example, you can create a view that displays only those authors who do not have a middle name, as follows:

=> CREATE VIEW authors_no_middle_name AS
    SELECT author_id, first_name, last_name
    FROM authors
    WHERE nullif(middle_name,'') IS NULL;
CREATE VIEW

Now this view can be used in queries almost like a regular table:

=> SELECT * FROM authors_no_middle_name;
 author_id | first_name |  last_name
-----------+------------+-------------
         2 | William    | Shakespeare
         3 | Terry      | Pratchett
         4 | Neil       | Gaiman
         6 | Jonathan   | Swift
(4 rows)

In a simple case, other operations can also be applied to a view, for example:

=> UPDATE authors_no_middle_name SET last_name = initcap(last_name);
UPDATE 4

In complex cases, you can use triggers to enable insert, update, and delete operations. We will explain it in the PL/pgSQL. Triggers lecture. At the planning stage, the view “unfolds”, revealing the base tables:

=> EXPLAIN (costs off)
SELECT * FROM authors_no_middle_name;
                    QUERY PLAN
---------------------------------------------------
 Seq Scan on authors
   Filter: (NULLIF(middle_name, ''::text) IS NULL)
(2 rows)

The application uses three views. They will be very simple at first, but later we’ll move some application logic into them. The authors view displays a concatenation of the first name, last name, and middle name (if available):

=> SELECT * FROM authors_v;
 author_id |         display_name
-----------+-------------------------------
         1 | Alexander Sergeyevich Pushkin
         5 | Ivan Alekseyevich Bunin
         7 | Jerome Klapka Jerome
         2 | William Shakespeare
         3 | Terry Pratchett
         4 | Neil Gaiman
         6 | Jonathan Swift
(7 rows)

The catalog view displays only the book title for now:

=> SELECT * FROM catalog_v \gx

Page 22: Page 22

-[ RECORD 1 ]+--------------------------
book_id      | 1
display_name | The Tale of Tsar Saltan
-[ RECORD 2 ]+--------------------------
book_id      | 2
display_name | Romeo and Juliet
-[ RECORD 3 ]+--------------------------
book_id      | 3
display_name | Good Omens
-[ RECORD 4 ]+--------------------------
book_id      | 4
display_name | Dark Avenues
-[ RECORD 5 ]+--------------------------
book_id      | 5
display_name | Travels into Several Remote Nations of the World. In Four Parts. By Lemuel
Gulliver, First a Surgeon, and then a Captain of Several Ships
-[ RECORD 6 ]+--------------------------
book_id      | 6
display_name | Three Men in a Boat (To Say Nothing of the Dog)
-[ RECORD 7 ]+--------------------------
book_id      | 7
display_name | 101 Famous Poems

The operations view specifies the operation type (arrival or sale):

=> SELECT * FROM operations_v;
 book_id | op_type | qty_change | date_created
---------+---------+------------+--------------
       1 | Arrival |         10 | 27.11.2025
       1 | Arrival |         10 | 27.11.2025
       1 | Sale    |          1 | 27.11.2025
(3 rows)

Page 23: Takeaways

Database design is a separate complex topic

  • theory is important, but it should not take precedence over common sense
  • Normalized data simplifies the development and facilitates consistency support
  • The API can use tables, views, functions, and triggers

Page 24: Practice

  1. Create bookstore database and the bookstore schema in it. Set up the search path to this schema at the database level. 2. In the bookstore schema, create books, authors, authorship, and operations tables with all the necessary constraints, exactly as shown in the demo. 3. Insert data about several books into the tables. Check the result by running some queries. 4. In the bookstore schema, create authors_v, catalog_v, and operations_v views, so that they look exactly like shown in the demo. Check that the application now shows the data in Books, Authors, and Catalog tabs.

Speaker notes

  1. Recall the contents of the Data Organization. Logical structure lesson.
2. Use the demonstrated output of psql’s \d commands as a reference.
  1. You can use the data shown in the demo, or come up with your own data.

  2. Try writing queries to the base tables that return the same results as the queries to views shown in the demo. Then save these queries as views.

After completing the assignments, make sure to compare your queries with those in the provided keys. Make corrections if necessary.

Page 25: 1. Schema and Search Path

=> CREATE DATABASE bookstore;
CREATE DATABASE
=> \c bookstore
You are now connected to database "bookstore" as user "student".
=> CREATE SCHEMA bookstore;
CREATE SCHEMA
=> ALTER DATABASE bookstore SET search_path = bookstore, public;
ALTER DATABASE
=> \c
You are now connected to database "bookstore" as user "student".
=> SHOW search_path;
    search_path
-------------------
 bookstore, public
(1 row)

2. Tables

Authors:

=> CREATE TABLE authors(
    author_id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    last_name text NOT NULL,
    first_name text NOT NULL,
    middle_name text
);
CREATE TABLE

Books:

=> CREATE TABLE books(
    book_id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    title text NOT NULL
);
CREATE TABLE

Authorship:

=> CREATE TABLE authorship(
    book_id integer REFERENCES books,
    author_id integer REFERENCES authors,
    seq_num integer NOT NULL,
    PRIMARY KEY (book_id,author_id)
);
CREATE TABLE

Operations:

=> CREATE TABLE operations(
    operation_id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    book_id integer NOT NULL REFERENCES books,
    qty_change integer NOT NULL,
    date_created date NOT NULL DEFAULT current_date
);
CREATE TABLE

3. Data

Authors:

Page 26: Page 26

=> INSERT INTO authors(last_name, first_name, middle_name)
VALUES
    ('Pushkin', 'Alexander', 'Sergeyevich'),
    ('Shakespeare', 'William', NULL),
    ('Pratchett', 'Terry', NULL),
    ('Gaiman', 'Neil', NULL),
    ('Bunin', 'Ivan', 'Alekseyevich'),
    ('Swift', 'Jonathan', NULL),
    ('Jerome', 'Jerome', 'Klapka');
INSERT 0 7

Books:

=> INSERT INTO books(title)
VALUES
    ('The Tale of Tsar Saltan'),
    ('Romeo and Juliet'),
    ('Good Omens'),
    ('Dark Avenues'),
    ('Travels into Several Remote Nations of the World. In Four Parts. '
'By Lemuel Gulliver, First a Surgeon, and then a Captain of Several Ships'),
    ('Three Men in a Boat (To Say Nothing of the Dog)'),
    ('101 Famous Poems');
INSERT 0 7

Authorship:

=> INSERT INTO authorship(book_id, author_id, seq_num)
VALUES
    (1, 1, 1),
    (2, 2, 1),
    (3, 3, 2),
    (3, 4, 1),
    (4, 5, 1),
    (5, 6, 1),
    (6, 7, 1),
    (7, 1, 1),
    (7, 5, 2),
    (7, 2, 3);
INSERT 0 10

Operations:

=> INSERT INTO operations(book_id, qty_change)
VALUES
    (1, 10),
    (1, 10),
    (1, -1);
INSERT 0 3

4. Views

Authors view:

=> CREATE VIEW authors_v AS
SELECT a.author_id,
       a.first_name ||
       coalesce(' ' || nullif(a.middle_name, ''), '') || ' ' ||
       a.last_name AS display_name
FROM   authors a;
CREATE VIEW

Catalog View:

=> CREATE VIEW catalog_v AS
SELECT b.book_id,
       b.title AS display_name
FROM   books b;
CREATE VIEW

Operations View:

Page 27: Page 27

=> CREATE VIEW operations_v AS
SELECT book_id,
       CASE
           WHEN qty_change > 0 THEN 'Arrival'
           ELSE 'Sale'
       END op_type,
       abs(qty_change) qty_change,
       to_char(date_created, 'DD.MM.YYYY') date_created
FROM   operations
ORDER BY operation_id;
CREATE VIEW

Page 28: Practice+

  1. What additional attributes can appear for these entities as the application evolves? 2. Suppose you have to store the information about the publisher. Extend the ER diagram accordingly and create the corresponding tables. 3. Some books can belong to a series (such as “The Adventure Collection”). How will it affect the schema? 4. Suppose our store started selling hardware equipment (motherboards, CPUs, memory, storage devices, monitors, etc.). What entities and attributes would you use to classify them? Keep in mind that new types of equipment with new specifications are released regularly.

Speaker notes

  1. Different publishers may have different book series with the same title.

Page 29: 1. Additional Attributes

Some examples: Authors: role (author, editor, translator, etc.); Books: summary; Operations: current status (paid, shipped, etc.).

2. Publishers

Add the Publisher entity with at least the Name attribute. Books are linked to publishers by a many-to-many relationship: a book can be published by different publishers. Therefore, at the physical level, you will need an intermediate Publications table with the publication year attribute. (Of course, this is a simplified model; it can be refined further if desired.)

3. Series

Add the Series entity. It is not the book itself that belongs to the series, but its specific publication, so it makes sense to bring the Publication table to the ER model level and associate it with the series in a one-to-many relationship (each publication belongs to one series, each series may include several publications). A series is also linked by a one-to-many relationship with a publisher (a publisher may have several series, and each series belongs to a specific publisher). But there are also off-series editions. These can be placed into a fictitious No series series, or by never specifying the foreign key for the publication.

4. Computer Parts

Considering each type of computer component, you can easily identify the necessary attributes. Some attributes will be shared (for example, the manufacturer and the model name), and some will make sense only for this specific type. For example: Processor: frequency; Monitor: diagonal, resolution; Hard drive: size, volume. The problem is that the computer parts market is very dynamic. Some time ago, hard drives were determined by the disk rotation speed and capacity, but now the type is important (solid-state, rotating, hybrid). In the days of CRT monitors, the refresh rate was important, but now it’s about the type of matrix. Nobody needs CD drives anymore, but flash drives are prevalent. And so on. Thus, either you will have to constantly change the data schema (and, therefore, constantly change the application that works with this data!), or look for a more universal model by abandoning rigid structure and consistency control. We discussed some universal models (for example, storing part of the data in JSON) in the presentation.

08 Lesson 08: SQL / Functions

Native Markdown source page: SQL / Functions
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_08_sql_func.html Archive: DEV1-handouts-16-en.zip PDF pages: 23

Page 1: SQL / Functions

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Functions and their Specifics in Databases
  • Parameters and Return Values
  • Passing Arguments in a Function Call
  • Volatility Categories and Query Planning

Page 3: Functions in Databases

The main goal is simplifying development tasks

  • interface (parameters) and implementation (function body)
  • abstracting from the main task when implementing a particular function
  • Traditional languages
  • PostgreSQL
  • side global variables whole database effects (volatility categories)
  • modules own interface namespaces, and implementation client and server
  • challenges overhead hiding the query related to calls from the planner (inlining) (inlining, subqueries, views)

Speaker notes

The main goal of introducing functions in programming is simplifying development tasks by decomposing them into smaller subtasks. Such simplification is possible because you can abstract from the big picture when thinking of a function. For this purpose, the function provides a precise interface to the outside world (parameters and the return value). Its implementation (the function body) can change; the caller does not see these changes and does not depend on them. This ideal situation can be messed up by the global state (global variables), and you have to keep in mind that in the DB context the whole database constitutes such a state.

In traditional programming languages, functions are often grouped into modules (packages, classes for OOP, etc.), which have their own interface and implementation. This separation into modules can be more or less arbitrary. In PostgreSQL, there is a fixed boundary between the client and the server: the server code deals with the database, while the client code manages transactions. There are no modules (or packages), only namespaces.

The only disadvantage of extensive use of functions in traditional languages is function call overhead. It is sometimes overcome by inlining function code into the calling program. In databases, the consequences can be more serious: if some part of the query is moved into a function, the planner stops seeing the big picture and cannot build a good query plan. In some cases, PostgreSQL can also perform inlining; alternatively, subqueries or views can be used.

Page 4: Overview

A database object

  • function declaration is stored in the system catalog
  • The structure of function declaration
    • name
    • parameters
    • return data type
    • body
  • Can be written in various languages, including SQL
    • the code is stored as a string literal
    • a function is interpreted when it is called
  • Is called in the context of an expression

Speaker notes

Functions are regular database objects, just like tables or indexes. Function declarations are stored in the system catalog; that’s why database functions are called stored functions.

PostgreSQL provides a lot of standard functions. Some of them are listed in the Basic Data Types and Functions handout.

You can also write your own functions in various programming languages. The information provided in this lecture applies to functions in any programming language, but we will use SQL in all examples.

Predictably, a function declaration consists of a name, optional parameters, a return data type, and a body. What may seem unexpected is that the body is written as a string literal, which contains the code written in the programming language of your choice. It makes function declarations look the same regardless of the used programming language. The body string is stored in the system catalog and is interpreted each time the function is called. Since PostgreSQL 14, SQL code can be pre-parsed. In this case the parse result is stored in the system catalog instead of the code itself. Another way to avoid interpretation is to write a function in the C language, but we are not going to discuss this approach here.

A function is always called within the context of an expression: in the list of expressions of the SELECT statement, in the WHERE clause, in CHECK constraints, etc.

https://postgrespro.com/docs/postgresql/16/sql-createfunction

https://postgrespro.com/docs/postgresql/16/sql-syntax-calling-funcs

Page 5: Functions without Parameters

Here is a simple example of a function with no parameters:

=> CREATE FUNCTION hello_world() -- function name and an empty list of parameters
RETURNS text                     -- the type of the return value
AS $$ SELECT 'Hello, world!'; $$ -- function body
LANGUAGE sql;                    -- language specification
CREATE FUNCTION

It is convenient to write the body as a dollar-quoted string, as shown in the example above. Otherwise, you have to take care of escaping quotes, which are sure to appear in the function body. Compare the following strings:

=> SELECT ' SELECT ''Hello, world!''; ';
         ?column?
---------------------------
  SELECT 'Hello, world!';
(1 row)
=> SELECT $$ SELECT 'Hello, world!'; $$;
         ?column?
---------------------------
  SELECT 'Hello, world!';
(1 row)

If required, dollar quoting can be nested. It is achieved by using different text strings between dollars in each pair of quotes:

=> SELECT $func$ SELECT $$Hello, world!$$; $func$;
          ?column?
-----------------------------
  SELECT $$Hello, world!$$;
(1 row)

A function is called in the context of an expression. For example:

=> SELECT hello_world(); -- empty brackets are mandatory
  hello_world
---------------
 Hello, world!
(1 row)

Let’s have a look at how the body of a function is stored in the system catalog.

=> SELECT proname, prosrc, prosqlbody FROM pg_proc
WHERE proname = 'hello_world' \gx
-[ RECORD 1 ]-------------------------
proname    | hello_world
prosrc     |  SELECT 'Hello, world!';
prosqlbody |

The function body is stored as-is in a text string. Let’s go the modern way and recreate the function in accordance with the SQL standard. Here, the body of the function will be just RETURN (so-called unquoted SQL function body):

=> CREATE OR REPLACE FUNCTION hello_world() RETURNS text
LANGUAGE sql
RETURN 'Hello, world!';
CREATE FUNCTION

Check the system catalog again: the function body is stored differently now.

=> SELECT proname, prosrc, left(prosqlbody, 100) AS body
FROM pg_proc
WHERE proname = 'hello_world' \gx

Page 6: Page 6

-[ RECORD 1 ]------------------------------------------------------------------------
proname | hello_world
prosrc  |
body    | {QUERY :commandType 1 :querySource 0 :canSetTag true :utilityStmt <>
:resultRelation 0 :hasAggs fals

This time, the source code is not stored here. You can get it with the \sf command:

=> \sf hello_world
CREATE OR REPLACE FUNCTION public.hello_world()
 RETURNS text
 LANGUAGE sql
RETURN 'Hello, world!'::text

If a function body contains multiple SQL statements, it will return the first row of the last operator’s output. For SQL functions with unquoted body, you will need to use the BEGIN ATOMIC … END construct to return the whole block of statements:

=> CREATE OR REPLACE FUNCTION hello_world() RETURNS text
LANGUAGE sql
BEGIN ATOMIC
  SELECT 'First Line';
  SELECT 'Second Line';
END;
CREATE FUNCTION

Let’s call the function:

=> SELECT hello_world();
 hello_world
-------------
 Second Line
(1 row)
Note

Note Note how the syntax of SQL functions with unquoted body is different from the traditional text string style: no AS construct with the function code as a text, the new keyword RETURN can be used to return a value, LANGUAGE sql clause is optional, function code is parsed and the parse result is stored in pg_proc.prosqlbody, while the source code itself is not stored in pg_proc.prosrc, unlike with the traditional notation. Not only does this conform to the standard better, but also improves compatibility with other SQL implementations. Now, when a function is called, its commands don’t need to go through interpretation again, and the parsed function body is used. Not all SQL statements can be used in a function. The following ones are disallowed: transaction control commands (BEGIN, COMMIT, ROLLBACK, etc.); service commands (such as VACUUM or CREATE INDEX). Here is an example of an invalid function. We have used the void pseudotype, which indicates that the function returns nothing.

=> CREATE FUNCTION do_commit() RETURNS void
LANGUAGE sql
BEGIN ATOMIC COMMIT; END;
ERROR:  COMMIT is not yet supported in unquoted SQL function body

You can use procedures to manage transactions; we will cover this topic in the next lecture.

Functions with Input Parameters

Here is a function with a single parameter:

=> CREATE FUNCTION hello(name text) -- a formal parameter
RETURNS text
LANGUAGE sql
RETURN 'Hello, ' || name || '!';
CREATE FUNCTION

When calling this function, we have to specify the actual value that corresponds to the formal parameter:

=> SELECT hello('Alice');

Page 7: Page 7

     hello
---------------
 Hello, Alice!
(1 row)

When specifying parameter types, you can add a modifier (such as varchar(10)), but it will be ignored. You can define a function parameter without a name; then the function body will have to refer to it by its position number. Let’s delete this function and create a new one:

=> DROP FUNCTION hello(text); -- it is enough to specify the parameter type
DROP FUNCTION
=> CREATE FUNCTION hello(text)
RETURNS text
LANGUAGE sql
RETURN 'Hello, ' || $1 || '!'; -- a number instead of the name
CREATE FUNCTION
=> SELECT hello('Alice');
     hello
---------------
 Hello, Alice!
(1 row)

But this approach is inconvenient and should be avoided. Let’s delete and recreate the function again, now adding two more parameters: a greeting and the title of a person.

=> DROP FUNCTION hello(text);
DROP FUNCTION

Here we have used an optional IN keyword, which means the input parameter. The DEFAULT clause is used to define the default parameter value:

=> CREATE FUNCTION hello(IN name text, IN greet text DEFAULT 'Dear', IN title text DEFAULT 'Mr')
RETURNS text
LANGUAGE sql
RETURN 'Hello, ' || greet || ' ' || title || ' ' || name || '!';
CREATE FUNCTION
=> SELECT hello('Alice', 'Charming', 'Mrs'); -- the second and the third parameter are specified
           hello
----------------------------
 Hello, Charming Mrs Alice!
(1 row)
Note

Note Note that parameters with default values must be at the end of the list. When calling a function, an argument for an optional parameter can be omitted. In this case, all following optional parameters will also use their default values.

=> SELECT hello('Bob', 'Excellent'); -- only the last parameter gets the default value
          hello
--------------------------
 Hello, Excellent Mr Bob!
(1 row)
=> SELECT hello('Bob'); -- both parameters with default values are omitted
        hello
---------------------
 Hello, Dear Mr Bob!
(1 row)

So far, we have provided function arguments as positional ones, in the order they were specified in the function declaration. In many standard functions, parameter names are not set, so it is the only way possible. But if the formal parameters are named, you can use these names when providing their actual values. In this case, parameters can be specified in any order:

=> SELECT hello(title => 'Dr.', name => 'Alice');

Page 8: Page 8

         hello
------------------------
 Hello, Dear Dr. Alice!
(1 row)

This approach is convenient if the order of parameters is not quite obvious, especially if there are a lot of them. You can combine both conventions: provide some parameters by position (starting from the first one) and specify the rest by name:

=> SELECT hello('Alice', title => 'Dr.');
         hello
------------------------
 Hello, Dear Dr. Alice!
(1 row)

If the function must return NULL when at least one of its input parameters is NULL, it can be declared STRICT. In this case, the function body will not be executed at all.

=> DROP FUNCTION hello(text, text, text);
DROP FUNCTION
=> CREATE FUNCTION hello(IN name text, IN title text DEFAULT 'Mr')
RETURNS text
LANGUAGE sql STRICT
RETURN 'Hello, ' || title || ' ' || name || '!';
CREATE FUNCTION
=> SELECT hello('Alice', NULL);
 hello
-------
(1 row)

Page 9: Input and Output

Input values

  • defined by parameters with IN or INOUT modes
  • Output value
    • defined either by the RETURNS clause
    • or by parameters with OUT or INOUT modes
    • if both forms are specified, they must correspond to each other

Speaker notes

Formal parameters that have IN or INOUT modes are input parameters. Their actual values must be specified in the function call (or the default values must be defined).

There are two ways to define the return value:

  • use the RETURNS clause to specify the return data type,

  • define output parameters using INOUT or OUT modes.

These two approaches are equivalent. For example, a function with the RETURNS integer clause and a function with the OUT integer parameter both return an integer number.

You can combine these two approaches. In this case, the function will also return one integer number. But note that the types of the output parameters and the RETURNS clause must not contradict each other.

Thus, unlike in many traditional programming languages, you cannot write a function that returns one value while passing another value into the OUT parameter.

Page 10: Functions with Output Parameters

An alternative way to return a value is to use an output parameter.

=> DROP FUNCTION hello(text, text);
DROP FUNCTION
=> CREATE FUNCTION hello(
    IN name text,
    OUT text -- you can omit the parameter name if it is not required
)
LANGUAGE sql
RETURN 'Hello, ' || name || '!';
CREATE FUNCTION
=> SELECT hello('Alice');
     hello
---------------
 Hello, Alice!
(1 row)

The result is the same. You can use the RETURNS clause and the OUT parameter together: the result will be the same anyway:

=> DROP FUNCTION hello(text); -- OUT parameters are omitted
DROP FUNCTION
=> CREATE FUNCTION hello(IN name text, OUT text)
RETURNS text
LANGUAGE sql
RETURN 'Hello, ' || name || '!';
CREATE FUNCTION
=> SELECT hello('Alice');
     hello
---------------
 Hello, Alice!
(1 row)

Or even use an INOUT parameter:

=> DROP FUNCTION hello(text);
DROP FUNCTION
=> CREATE FUNCTION hello(INOUT name text)
LANGUAGE sql
RETURN 'Hello, ' || name || '!';
CREATE FUNCTION
=> SELECT hello('Alice');
     hello
---------------
 Hello, Alice!
(1 row)
Note

Note Note that the actual value passed to the SQL function in an INOUT parameter is not modified: we pass an input value, and the output value is returned as a result (SQL differs from many other programming languages in this respect). That’s why we can pass a constant, although other languages would require a variable. While the RETURNS clause can take only one value, there can be several output parameters. For example:

=> DROP FUNCTION hello(text);
DROP FUNCTION

Page 11: Page 11

=> CREATE FUNCTION hello(
    IN name text,
    OUT greeting text,
    OUT clock timetz)
LANGUAGE sql
RETURN ('Hello, ' || name || '!', current_time);
CREATE FUNCTION

Here, the expression after RETURN has to be in parentheses.

=> SELECT hello('Alice');
                hello
-------------------------------------
 ("Hello, Alice!",14:20:36.03188+03)
(1 row)

Indeed, our function has returned not just one but several values at once. We will provide more details about this feature and composite types in the SQL. Composite Types lecture.

Page 12: Volatility Categories

Volatile

  • may return different values for the same input arguments
  • is used by default
  • Stable
    • the return value cannot change within a single SQL operator
    • the function cannot change the database state
  • Immutable
    • the return value cannot change, the function is deterministic
    • the function cannot change the database state

Speaker notes

Each function is mapped to a particular volatility category, which defines the properties of the return value for the same input arguments.

The volatile category means that the return value can change randomly. Such functions will be executed each time they are called. If the function is declared without a category specification, it is assumed to be volatile.

The stable category is used for functions that always return the same value within a single SQL operator. In particular, such functions cannot change the state of the database. PostgreSQL could execute such a function only once during the query and then use the computed value.

The immutable category is even more strict: the return value always remains the same. Such a function could be executed at the planning stage, before the query is actually executed.

It does not mean that it happens so all the time, but the planner has the right to perform such optimizations. In some (simple) cases, the planner makes its own assumptions about function volatility, regardless of the explicitly provided category.

https://postgrespro.com/docs/postgresql/16/xfunc-volatility

Page 13: Volatility Categories and Isolation

In general, using functions within queries does not violate the isolation level of the transaction, but there are two points worth knowing. First, volatile functions can cause data inconsistency within the query when used at the Read Committed level. Let’s create a function that returns the number of rows in a table:

=> CREATE TABLE t(n integer);
CREATE TABLE
=> CREATE FUNCTION cnt() RETURNS bigint
LANGUAGE sql VOLATILE
RETURN (SELECT count(*) FROM t);
CREATE FUNCTION

Now let’s call it several times with a delay and insert a row into the table in a parallel session.

=> BEGIN ISOLATION LEVEL READ COMMITTED;
BEGIN
=> SELECT (SELECT count(*) FROM t), cnt(), pg_sleep(1)
FROM generate_series(1,4);
=> INSERT INTO t VALUES (1);
INSERT 0 1
 count | cnt | pg_sleep
-------+-----+----------
     0 |   0 |
     0 |   0 |
     0 |   1 |
     0 |   1 |
(4 rows)
=> END;
COMMIT

It won’t happen at stricter isolation levels, or if the function is stable or immutable.

=> ALTER FUNCTION cnt() STABLE;
ALTER FUNCTION
=> TRUNCATE t;
TRUNCATE TABLE
=> BEGIN ISOLATION LEVEL READ COMMITTED;
BEGIN
=> SELECT (SELECT count(*) FROM t), cnt(), pg_sleep(1)
FROM generate_series(1,4);
=> INSERT INTO t VALUES (1);
INSERT 0 1
 count | cnt | pg_sleep
-------+-----+----------
     0 |   0 |
     0 |   0 |
     0 |   0 |
     0 |   0 |
(4 rows)
=> END;
COMMIT

Page 14: Page 14

Another point is the visibility of changes made by the same transaction. Volatile functions can see all the changes, even those made by the current SQL operator that has not been completed yet.

=> ALTER FUNCTION cnt() VOLATILE;
ALTER FUNCTION
=> TRUNCATE t;
TRUNCATE TABLE
=> INSERT INTO t SELECT cnt() FROM generate_series(1,5);
INSERT 0 5
=> SELECT * FROM t;
 n
---
 0
 1
 2
 3
 4
(5 rows)

It is true for any isolation level. Stable and immutable functions see only the changes performed by an already completed operator.

=> ALTER FUNCTION cnt() STABLE;
ALTER FUNCTION
=> TRUNCATE t;
TRUNCATE TABLE
=> INSERT INTO t SELECT cnt() FROM generate_series(1,5);
INSERT 0 5
=> SELECT * FROM t;
 n
---
 0
 0
 0
 0
 0
(5 rows)

Volatility Categories and Query Planning

Thanks to the volatility labels which provide additional information about the function behavior, the optimizer can spare some function calls. To try it out, let’s create a function that returns a random number:

=> CREATE FUNCTION rnd() RETURNS float
LANGUAGE sql VOLATILE
RETURN random();
CREATE FUNCTION

Let’s check the execution plan of the following query:

=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
                   QUERY PLAN
------------------------------------------------
 Function Scan on generate_series
   Filter: (random() > '0.5'::double precision)
(2 rows)

The query plan shows that the random() function is honestly called several times; each result returned by the generate_series call is compared with a random number and filtered out, if required.

Page 15: Page 15

You can see it for yourself:

=> SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
 generate_series
-----------------
               1
               2
               3
               5
               8
               9
              10
(7 rows)
=> \g
 generate_series
-----------------
               2
               3
               6
               8
              10
(5 rows)
=> \g
 generate_series
-----------------
               2
               4
               8
               9
(4 rows)
=> \g
 generate_series
-----------------
               1
               4
               5
               6
               7
               8
              10
(7 rows)
=> \g
 generate_series
-----------------
               1
               5
               8
               9
              10
(5 rows)

Here, we randomly get 0 to 10 rows. A stable function will be called only once, because we have virtually specified that its value cannot change within a single operator:

=> ALTER FUNCTION rnd() STABLE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
                      QUERY PLAN
------------------------------------------------------
 Result
   One-Time Filter: (rnd() > '0.5'::double precision)
   ->  Function Scan on generate_series
(3 rows)

Page 16: Page 16

The output will be either 0 or 10 rows.

=> SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
 generate_series
-----------------
(0 rows)
=> \g
 generate_series
-----------------
(0 rows)
=> \g
 generate_series
-----------------
               1
               2
               3
               4
               5
               6
               7
               8
               9
              10
(10 rows)
=> \g
 generate_series
-----------------
               1
               2
               3
               4
               5
               6
               7
               8
               9
              10
(10 rows)

Finally, immutable functions are computed at the planning stage, so we do not need any filters during execution:

=> ALTER FUNCTION rnd() IMMUTABLE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
            QUERY PLAN
----------------------------------
 Function Scan on generate_series
(1 row)
=> \g
        QUERY PLAN
--------------------------
 Result
   One-Time Filter: false
(2 rows)
=> \g
        QUERY PLAN
--------------------------
 Result
   One-Time Filter: false
(2 rows)

Page 17: Function Inlining

The plan for immutable is random! It is the developer’s responsibility to provide the correct information. In some (very simple) cases, a function can be inlined: the function body written in SQL can be inserted right into the main SQL operator while the query is being parsed. In this case, we can save some time on the function call. Roughly speaking, the following conditions should be met: The function body contains only one SELECT operator. The function does not access any tables. There are no subqueries, grouping operations, etc. There must be only one return value. The called functions must not violate the specified volatility category. We have already seen such an example: the rnd() function, which is declared volatile. Let’s take another look.

=> ALTER FUNCTION rnd() VOLATILE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
                   QUERY PLAN
------------------------------------------------
 Function Scan on generate_series
   Filter: (random() > '0.5'::double precision)
(2 rows)

The Filter does not mention the rnd() function, only random() is present; it will be called directly, without using the rnd() wrapper.

Page 18: Takeaways

You can create your own functions

  • Functions can be written in various languages, including SQL
  • Volatility categories affect optimization opportunities
  • An SQL function can sometimes be inlined

Page 19: Practice

  1. Create a function author_name to construct author names. The function takes three parameters (last_name, first_name, and middle_name) and returns the full name, with the middle name abbreviated to its initial.
  • Use this function in the authors_v view. 2. Create a function book_name to construct book titles. The function takes two parameters (book ID and title) and returns a concatenation of the book title and the list of authors, ordered by seq_num. The name of each author is produced by the author_name function.
  • Use this function in the catalog_v view. Check the changes in the application.

Speaker notes

Reminder: all the required functions are listed in Basic Data Types and Functions handout.

1. FUNCTION author_name(
 last_name text, first_name text, middle_name text
)
RETURNS text
For example: author_name('Alexander','Sergeyevich', 'Pushkin')→
→ 'Alexander S. Pushkin'
3. FUNCTION book_name(book_id integer, title text)
RETURNS text
For example: book_name(3,'Good Omens') →
→ 'Good Omens. Terry Pratchett, Neil Gaiman'

Stored functions can be edited directly. For example, psql provides the \ef command that opens the function source code in an editor and saves the changes in the database.

You should avoid using this capability (or at least do not overuse it). A properly set up development process requires that all the code is stored in files under version control. If a function has to be changed, the file is modified and executed (using psql or an IDE). Function modifications made directly in the database can be easily lost. (In fact, setting up development processes is much more complex, but we are not going to cover it in this course.)

Page 20: 1. The author_name Function

=> CREATE FUNCTION author_name(
    last_name text,
    first_name text,
    middle_name text
) RETURNS text
LANGUAGE sql IMMUTABLE
RETURN first_name ||
       CASE WHEN middle_name != '' -- NOT NULL is implied
           THEN ' ' || left(middle_name, 1) || '.'
           ELSE ''
       END || ' ' ||
       last_name;
CREATE FUNCTION

Volatility: immutable. The function always returns the same value given the same input arguments.

=> CREATE OR REPLACE VIEW authors_v AS
SELECT a.author_id,
       author_name(a.last_name, a.first_name, a.middle_name) AS display_name
FROM   authors a
ORDER BY display_name;
CREATE VIEW

2. The book\_name Function

=> CREATE FUNCTION book_name(book_id integer, title text)
RETURNS text
LANGUAGE sql STABLE
RETURN (
SELECT title || '. ' ||
       string_agg(
           author_name(a.last_name, a.first_name, a.middle_name), ', '
           ORDER BY ash.seq_num
       )
FROM   authors a
       JOIN authorship ash ON a.author_id = ash.author_id
WHERE  ash.book_id = book_name.book_id
);
CREATE FUNCTION

Volatility: stable. The function returns the same value given the same input arguments, but only within a single SQL query.

=> CREATE OR REPLACE VIEW catalog_v AS
SELECT b.book_id,
       book_name(b.book_id, b.title) AS display_name
FROM   books b
ORDER BY display_name;
CREATE VIEW

Page 21: Practice+

  1. Write a function that returns random timestamps, equally distributed within the specified time range. The lower bound of the range is set by a timestamptz value, while the upper bound is either a timestamptz or an interval. 2. Sometimes, phonewords are used as a mnemonic equivalent of telephone numbers, like 1-800-TAXICAB. A phoneword is constructed by replacing some digits with the corresponding letters, as seen on the keypad (even on modern phones). Write a function that returns number representation. 3. Write a function that solves a quadratic equation.

Speaker notes

In all tasks, make sure to pay attention to volatility categories of the functions.

  1. The correspondence between digits and letters is as follows: 2 – abc, 3 – def, 4 – ghi, 5 – jkl, 6 – mno, 7 – pqrs, 8 – tuv, 9 – wxyz

Note that a phoneword can be written in lowercase as well as in uppercase.

  1. For the equation y = ax2 + bx + c the discriminant is D = b2 – 4ac:
  • for D > 0, the two roots are x1,2 = (–b ± √D) / 2a

  • for D = 0, the only root is x = –b / 2a (null can be returned as x2)

  • when D < 0 there are no roots (both roots are null)

Page 22: 1. Random Timestamp

=> CREATE DATABASE sql_func;
CREATE DATABASE
=> \c sql_func
You are now connected to database "sql_func" as user "student".

A function with two timestamps:

=> CREATE FUNCTION rnd_timestamp(t_start timestamptz, t_end timestamptz)
RETURNS timestamptz
LANGUAGE sql VOLATILE
RETURN t_start + (t_end - t_start) * random();
CREATE FUNCTION

Volatility: volatile. The random function will return different values with the same input.

=> SELECT current_timestamp,
    rnd_timestamp(
        current_timestamp,
        current_timestamp + interval '1 hour'
    )
FROM generate_series(1,10);
       current_timestamp       |         rnd_timestamp
-------------------------------+-------------------------------
 2025-11-27 14:28:20.104506+03 | 2025-11-27 15:14:05.637861+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:51:57.641358+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:58:28.707004+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:33:58.232028+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:47:22.947406+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:43:24.923114+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:33:34.110874+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:50:03.860486+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 15:25:29.804819+03
 2025-11-27 14:28:20.104506+03 | 2025-11-27 14:58:10.923546+03
(10 rows)

The second function (with the interval parameter) can be defined using the first one:

=> CREATE FUNCTION rnd_timestamp(t_start timestamptz, t_delta interval)
RETURNS timestamptz
LANGUAGE sql VOLATILE
RETURN rnd_timestamp(t_start, t_start + t_delta);
CREATE FUNCTION
=> SELECT rnd_timestamp(current_timestamp, interval '1 hour');
         rnd_timestamp
-------------------------------
 2025-11-27 15:06:18.937972+03
(1 row)

2. Phonewords

A function to convert a phoneword to number:

=> CREATE FUNCTION phoneword_to_number(phoneword text) RETURNS numeric
LANGUAGE sql IMMUTABLE
RETURN translate(
    lower(phoneword),
    'abcdefghijklmnopqrstuvwxyz-',
    '22233344455566677778889999'
)::numeric;
CREATE FUNCTION

Volatility category: immutable. The function always returns the same value given the same input arguments.

=> SELECT phoneword_to_number('1-800-TAXICAB');

Page 23: 3. The Roots of a Quadratic Equation

 phoneword_to_number
---------------------
         18008294222
(1 row)
=> CREATE FUNCTION square_roots(
    a float,
    b float,
    c float,
    x1 OUT float,
    x2 OUT float
)
LANGUAGE sql IMMUTABLE
RETURN (
WITH discriminant(d) AS (
    SELECT b*b - 4*a*c
)
SELECT (CASE WHEN d >= 0.0 THEN (-b + sqrt(d))/2/a END,
        CASE WHEN d >  0.0 THEN (-b - sqrt(d))/2/a END)
FROM discriminant
);
CREATE FUNCTION

Volatility category: immutable. The function always returns the same value for the same input parameters.

=> SELECT square_roots(1,  0, -4);
 square_roots
--------------
 (2,-2)
(1 row)
=> SELECT square_roots(1, -4,  4);
 square_roots
--------------
 (2,)
(1 row)
=> SELECT square_roots(1,  1,  1);
 square_roots
--------------
 (,)
(1 row)
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE sql_func;
DROP DATABASE

09 Lesson 09: SQL / Procedures

Native Markdown source page: SQL / Procedures
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_09_sql_proc.html Archive: DEV1-handouts-16-en.zip PDF pages: 19

Page 1: SQL / Procedures

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Procedures and their Differences from Functions
  • Input and Output Parameters
  • Overloading and Polymorphism

Page 3: Routines

Functions

  • called in the context of an expression
  • cannot manage transactions
  • return a result
  • Procedures
    • called using a CALL statement
    • can manage transactions
    • can return a result

Speaker notes

Procedures were first introduced in PostgreSQL 11. The main reason for their introduction was that functions cannot manage transactions. Functions are called in the context of some expression which is computed as part of an already started operator (such as SELECT) in an already started transaction. It is impossible to complete a transaction and then start a new one while the operator is being executed.

Procedures are always called by a special CALL statement. If this statement starts a new transaction (instead of being called from an already started one), then it is possible to use transaction management commands in the called procedure.

Unfortunately, procedures written in SQL cannot use COMMIT and ROLLBACK commands (although those with unquoted body may be able to in the future). Therefore, we won’t see an example of a procedure that manages transactions until we get to the PL/pgSQL. Query execution section.

Some say that the difference between functions and procedures is that a procedure does not return a result. But it is not true: procedures can also return a result, if required.

An umbrella term for both functions and procedures is routines. They share the common namespace.

https://postgrespro.com/docs/postgresql/16/sql-createprocedure

https://postgrespro.com/docs/postgresql/16/sql-call

Page 4: Procedures without Parameters

Let’s start with an example of a simple procedure with no parameters.

=> CREATE TABLE t(a float);
CREATE TABLE
=> CREATE PROCEDURE fill()
AS $$
    TRUNCATE t;
    INSERT INTO t SELECT random() FROM generate_series(1,3);
$$ LANGUAGE sql;
CREATE PROCEDURE

To call a procedure, you have to use a CALL statement:

=> CALL fill();
CALL

Take a look at the result in the table:

=> SELECT * FROM t;
         a
--------------------
  0.269508448980468
 0.2572845568619859
 0.7262705855628093
(3 rows)

Let’s define the procedure again, now with unquoted body:

=> CREATE OR REPLACE PROCEDURE fill()
LANGUAGE sql
BEGIN ATOMIC
    DELETE FROM t;  -- TRUNCATE is not yet supported in such procedures
    INSERT INTO t SELECT random() FROM generate_series(1,3);
END;
CREATE PROCEDURE

Check if it works:

=> CALL fill();
CALL
=> SELECT * FROM t;
          a
---------------------
  0.5542507248343103
 0.07157639040029196
  0.8603393100655552
(3 rows)

Try committing a transaction within the procedure:

=> CREATE OR REPLACE PROCEDURE fill()
LANGUAGE sql
BEGIN ATOMIC
    DELETE FROM t;
    INSERT INTO t SELECT random() FROM generate_series(1,3);
    COMMIT;
END;
ERROR:  COMMIT is not yet supported in unquoted SQL function body
Note

Note Note that we get the invalid command error already at the procedure definition stage. Rename the table the procedure is working with:

=> ALTER TABLE t RENAME TO ta;
ALTER TABLE

Page 5: Page 5

The call below will not result in an error. In the procedure definition in the system catalog, the table is specified not by name but by ID, which was obtained at procedure creation.

=> CALL fill();
CALL

Similar behavior can be achieved with a function which returns the output of its last operator. You can define the return type as void if the function does not return anything, or specify the actual result value type. Let’s give the table back its previous name and define the function:

=> ALTER TABLE ta RENAME TO t;
ALTER TABLE
=> CREATE FUNCTION fill_avg() RETURNS float
LANGUAGE sql
BEGIN ATOMIC
    DELETE FROM t;
    INSERT INTO t SELECT random() FROM generate_series(1, 3);
    SELECT avg(a) FROM t;
END;
CREATE FUNCTION

In any case, a function is always called in the context of some expression:

=> SELECT fill_avg();
      fill_avg
--------------------
 0.6875661870656832
(1 row)
=> SELECT * FROM t;
         a
--------------------
 0.8898635547654432
 0.3907826147818403
 0.7820523916497659
(3 rows)

Functions cannot manage transactions. But SQL procedures do not support it either (although procedures written in other languages do provide such support).

Procedures with Parameters

Let’s add an input parameter that defines the number of rows:

=> DROP PROCEDURE fill();
DROP PROCEDURE
=> CREATE PROCEDURE fill(nrows integer)
LANGUAGE sql
BEGIN ATOMIC
    DELETE FROM t;
    INSERT INTO t SELECT random() FROM generate_series(1, nrows);
END;
CREATE PROCEDURE

Just like functions, procedures allow passing arguments by position or by name:

=> CALL fill(nrows => 5);
CALL
=> SELECT * FROM t;

Page 6: Page 6

          a
---------------------
  0.7622007860116502
  0.9574269589997921
 0.16014709786642167
  0.9578109049668733
  0.1632142083934891
(5 rows)

Procedures can also have OUT and INOUT parameters that can be used to return a value.

=> DROP PROCEDURE fill(integer);
DROP PROCEDURE
=> CREATE PROCEDURE fill(IN nrows integer, OUT average float)
LANGUAGE sql
BEGIN ATOMIC
    DELETE FROM t;
    INSERT INTO t SELECT random() FROM generate_series(1, nrows);
    SELECT avg(a) FROM t; -- like in a function
END;
CREATE PROCEDURE

Let’s try it out:

=> CALL fill(5, NULL /* the value is not used but has to be specified */);
      average
--------------------
 0.5597842124557506
(1 row)

Page 7: Overloading

Several routines with the same name

  • routine is uniquely identified by a signature:
  • its name and input parameter types
  • types of the return value and output parameters are ignored
  • an appropriate routine is selected during execution based on the argument
  • types
  • CREATE OR REPLACE command
    • for new combination of input parameter types,
    • creates a new overloaded routine
    • for existing combination of input parameter types,
    • changes the corresponding routine, but not the type of the return value

Speaker notes

Overloading is the ability to use one and the same name for several routines (functions or procedures), which differ in types of IN and INOUT parameters.

In other words, a routine name and types of its input parameters form a routine signature. When calling a routine, PostgreSQL finds its version that corresponds to the passed arguments. If an appropriate routine cannot be determined unambiguously, a runtime error occurs.

A signature, however, does not include:

  • routine type (procedure or function),

  • OUT parameter types,

  • returned value type.

You have to take overloading into account when executing CREATE OR REPLACE (FUNCTION or PROCEDURE). If input parameter types differ from those used by already existing routines, a new overloaded routine will be created, otherwise a matching existing one will be replaced. Besides, when an existing routine is replaced with the CREATE OR REPLACE command, its type, OUT parameter types and return value type may not be changed, but other properties such as the language can be. In some cases, this means you must delete the routine and create it anew to replace it. However, doing so requires you to first delete all dependent objects, such as views, triggers, and other routines (DROP ROUTINE … CASCADE).

https://postgrespro.com/docs/postgresql/16/xfunc-overload

Page 8: Polymorphism

A routine that takes arguments of various types

  • formal parameters use polymorphic pseudotypes
  • (such as anyelement or anycompatible)
  • the actual data type is selected during execution
  • based on the type of the passed arguments

Speaker notes

Instead of having several overloaded routines for different types, it is sometimes more convenient to create a single routine that takes arguments of any (or almost any) type.

For this purpose, a special polymorphic pseudotype is used as the formal parameter type. For now, we will consider just two of them — anyelement and anycompatible — with more to follow in later sections.

A routine defined with polymorphic pseudotypes as input parameters may take any data type as input. The exact type to be used by the routine is selected at run time based on the type of the passed argument.

If a routine is defined with multiple polymorphic parameters of the anyelement type, all passed arguments will be implicitly converted to the type of the first parameter. On the other hand, if a routine is defined with multiple polymorphic parameters of the anycompatible type, all passed arguments will be converted to some common type.

If a function is declared with a polymorphic return value, it must have at least one polymorphic input parameter. The exact type of the return value is also defined by the actual type of the passed input argument. For SQL routines with unquoted body, there is no way to use polymorphic data types for arguments.

https://postgrespro.com/docs/postgresql/16/extend-type-system#EXTEND-T YPES-POLYMORPHIC

https://postgrespro.com/docs/postgresql/16/xfunc-sql#XFUNC-SQL-POLYM ORPHIC-FUNCTIONS

Page 9: Overloaded Routines

Overloading mechanism is the same for both functions and procedures. They have a common namespace. As an example, let’s create a function that compares two integer numbers and returns the largest value. (There is a similar SQL expression called greatest, but we’ll write our own function here.)

=> CREATE FUNCTION maximum(a integer, b integer) RETURNS integer
LANGUAGE sql
RETURN CASE WHEN a > b THEN a ELSE b END;
CREATE FUNCTION

Let’s check:

=> SELECT maximum(10, 20);
 maximum
---------
      20
(1 row)

Suppose we decided to create a similar function for three numbers. Thanks to overloading, we do not need to invent a new name:

=> CREATE FUNCTION maximum(a integer, b integer, c integer)
RETURNS integer
LANGUAGE sql
RETURN CASE
         WHEN a > b THEN maximum(a, c)
         ELSE maximum(b, c)
       END;
CREATE FUNCTION

Now we have two functions with the same name but a different number of parameters:

=> \df maximum
                              List of functions
 Schema |  Name   | Result data type |       Argument data types       | Type
--------+---------+------------------+---------------------------------+------
 public | maximum | integer          | a integer, b integer            | func
 public | maximum | integer          | a integer, b integer, c integer | func
(2 rows)

And both of them work:

=> SELECT maximum(10, 20), maximum(10, 20, 30);
 maximum | maximum
---------+---------
      20 |      30
(1 row)

The CREATE OR REPLACE command enables you to create a routine or replace an existing one without deleting it. Since a function with such a signature already exists, it will be replaced:

=> CREATE OR REPLACE FUNCTION maximum(a integer, b integer, c integer)
RETURNS integer
LANGUAGE sql
RETURN CASE
         WHEN a > b THEN
           CASE WHEN a > c THEN a ELSE c END
         ELSE
           CASE WHEN b > c THEN b ELSE c END
         END;
CREATE FUNCTION

Let our function support not only integers but also real numbers. How can we implement it? We could define one more function as follows:

=> CREATE FUNCTION maximum(a real, b real) RETURNS real
LANGUAGE sql
RETURN CASE WHEN a > b THEN a ELSE b END;

Page 10: Page 10

CREATE FUNCTION

Now we have three functions with the same name:

=> \df maximum
                              List of functions
 Schema |  Name   | Result data type |       Argument data types       | Type
--------+---------+------------------+---------------------------------+------
 public | maximum | integer          | a integer, b integer            | func
 public | maximum | integer          | a integer, b integer, c integer | func
 public | maximum | real             | a real, b real                  | func
(3 rows)

Two of them have the same number of parameters that differ in types:

=> SELECT maximum(10, 20), maximum(1.1, 2.2);
 maximum | maximum
---------+---------
      20 |     2.2
(1 row)

If a routine is overloaded multiple times, you can output information on specific overloads in \df by specifying parameter types:

=> \df maximum real
                        List of functions
 Schema |  Name   | Result data type | Argument data types | Type
--------+---------+------------------+---------------------+------
 public | maximum | real             | a real, b real      | func
(1 row)

Then we would have to define separate functions with exactly the same body for all other data types, and repeat it for three parameters.

Polymorphic Routines

We can use the polymorphic types anyelement and anycompatible. These are pseudotypes, and when a function is called and interpreted, they are substituted with actual argument types. Naturally, if a routine is defined with unquoted body, its code is parsed at creation, preventing the use of pseudotypes. Let’s delete all the three functions that we have created…

=> DROP FUNCTION maximum(integer, integer);
DROP FUNCTION
=> DROP FUNCTION maximum(integer, integer, integer);
DROP FUNCTION
=> DROP FUNCTION maximum(real, real);
DROP FUNCTION

…and then create a new one:

=> CREATE FUNCTION maximum(a anyelement, b anyelement)
RETURNS anyelement
AS $$
    SELECT CASE WHEN a > b THEN a ELSE b END;
$$ LANGUAGE sql;
CREATE FUNCTION

This function should accept any data type (but will work only with those types for which the “greater than” operator is defined). Will it work?

=> SELECT maximum('A', 'B');
ERROR:  could not determine polymorphic type because input has type unknown

Unfortunately not. In this case, string literals can be of the char, varchar, or text type; the exact type is unknown. But we can use explicit type casting:

=> SELECT maximum('A'::text, 'B'::text);

Page 11: Page 11

 maximum
---------
 B
(1 row)

Here is another example with a different type:

=> SELECT maximum(now(), now() + interval '1 day');
            maximum
-------------------------------
 2025-11-28 14:21:06.269648+03
(1 row)

The type of the result value will always be the same as the parameter type. But we could go further and make polymorphic routines take not just the same types but compatible ones: those that can be implicitly converted into each other. This is where the polymorphic pseudotype anycompatible comes in. Let’s recreate our function:

=> DROP FUNCTION maximum;
DROP FUNCTION
=> CREATE FUNCTION maximum(a anycompatible, b anycompatible)
RETURNS anycompatible
AS $$
    SELECT CASE WHEN a > b THEN a ELSE b END;
$$ LANGUAGE sql;
CREATE FUNCTION

Try the literals again:

=> SELECT maximum('A', 'B');
 maximum
---------
 B
(1 row)

It works! But if the types are neither the same nor compatible, we get an error:

=> SELECT maximum(1, 'A');
ERROR:  invalid input syntax for type integer: "A"
LINE 1: SELECT maximum(1, 'A');
                          ^

In this example, such a requirement looks quite natural, but it may turn out to be inconvenient in some other cases. Now let’s create a function with three parameters, so that the third parameter is optional.

=> CREATE FUNCTION maximum(
    a anycompatible,
    b anycompatible,
    c anycompatible DEFAULT NULL
) RETURNS anycompatible
AS $$
SELECT CASE
        WHEN c IS NULL THEN
            x
        ELSE
            CASE WHEN x > c THEN x ELSE c END
    END
FROM (
    SELECT CASE WHEN a > b THEN a ELSE b END
) max2(x);
$$ LANGUAGE sql;
CREATE FUNCTION
=> SELECT maximum(10, 11.21, 3e3);

Page 12: Page 12

 maximum
---------
    3000
(1 row)

It works. And what about the following query?

=> SELECT maximum(10, 11.21);
ERROR:  function maximum(integer, numeric) is not unique
LINE 1: SELECT maximum(10, 11.21);
               ^
HINT:  Could not choose a best candidate function. You might need to add explicit type
casts.

A conflict occurs between two overloaded functions:

=> \df maximum
                                                  List of functions
 Schema |  Name   | Result data type |                           Argument data types
                     | Type
--------+---------+------------------+----------------------------------------------------
---------------------+------
 public | maximum | anycompatible    | a anycompatible, b anycompatible
                     | func
 public | maximum | anycompatible    | a anycompatible, b anycompatible, c anycompatible
DEFAULT NULL::unknown | func
(2 rows)

It’s impossible to understand whether we meant to run the function with two parameters, or simply omitted the third one. This conflict can be easily resolved: let’s delete the first function as it is no longer required.

=> DROP FUNCTION maximum(anycompatible, anycompatible);
DROP FUNCTION
=> SELECT maximum(10, 11.21), maximum(10, 11.21, 3e3);
 maximum | maximum
---------+---------
   11.21 |    3000
(1 row)

Now everything works fine. Once we get to the PL/pgSQL. Arrays lecture, we will also learn how to define routines with an arbitrary number of parameters.

Page 13: Takeaways

You can create and use custom procedures

  • Unlike functions, procedures are called using the CALL statement and can manage transactions
  • Both procedures and functions support overloading and polymorphism

Page 14: Practice

  1. In the authors table, authors’ first, last and middle names must be unique, but this condition is not checked. Create a procedure that deletes possible duplicates. 2. To eliminate the need for such a procedure, create a constraint that will not allow entering duplicates in the future.

Speaker notes

  1. The feature for adding new authors won’t appear in the application until we get to the PL/pgSQL. Query execution section. For now, you can insert duplicates manually to check if your solution works.

Page 15: Task 1. Eliminating Duplicates

To test the solution, add another Pushkin:

=> INSERT INTO authors(last_name, first_name, middle_name)
    VALUES ('Pushkin', 'Alexander', 'Sergeyevich');
INSERT 0 1
=> SELECT last_name, first_name, middle_name, count(*)
FROM authors
GROUP BY last_name, first_name, middle_name;
  last_name  | first_name | middle_name  | count
-------------+------------+--------------+-------
 Jerome      | Jerome     | Klapka       |     1
 Pushkin     | Alexander  | Sergeyevich  |     2
 Gaiman      | Neil       |              |     1
 Swift       | Jonathan   |              |     1
 Bunin       | Ivan       | Alekseyevich |     1
 Shakespeare | William    |              |     1
 Pratchett   | Terry      |              |     1
(7 rows)

There are several ways to eliminate duplicates. One example is:

=> CREATE PROCEDURE authors_dedup()
LANGUAGE sql
BEGIN ATOMIC
DELETE FROM authors
WHERE author_id IN (
    SELECT author_id
    FROM (
        SELECT author_id,
               row_number() OVER (
                   PARTITION BY first_name, last_name, middle_name
                   ORDER BY author_id
               ) AS rn
        FROM authors
    ) t
    WHERE t.rn > 1
);
END;
CREATE PROCEDURE
=> CALL authors_dedup();
CALL
=> SELECT last_name, first_name, middle_name, count(*)
FROM authors
GROUP BY last_name, first_name, middle_name;
  last_name  | first_name | middle_name  | count
-------------+------------+--------------+-------
 Jerome      | Jerome     | Klapka       |     1
 Pushkin     | Alexander  | Sergeyevich  |     1
 Gaiman      | Neil       |              |     1
 Swift       | Jonathan   |              |     1
 Bunin       | Ivan       | Alekseyevich |     1
 Shakespeare | William    |              |     1
 Pratchett   | Terry      |              |     1
(7 rows)

Task 2. Using Constraints

We cannot define a suitable constraint because a middle name can be NULL. NULL values are considered to be different, so the constraint

UNIQUE(first_name, last_name, middle_name)

will still allow you to add another Jonathan Swift without a middle name. The problem can be solved by creating a unique index:

Page 16: Page 16

=> CREATE UNIQUE INDEX authors_full_name_idx ON authors(
    last_name, first_name, coalesce(middle_name,'')
);
CREATE INDEX

Let’s check the result:

=> INSERT INTO authors(last_name, first_name)
    VALUES ('Swift', 'Jonathan');
ERROR:  duplicate key value violates unique constraint "authors_full_name_idx"
DETAIL:  Key (last_name, first_name, COALESCE(middle_name, ''::text))=(Swift, Jonathan, )
already exists.
=> INSERT INTO authors(last_name, first_name, middle_name)
    VALUES ('Pushkin', 'Alexander', 'Sergeyevich');
ERROR:  duplicate key value violates unique constraint "authors_full_name_idx"
DETAIL:  Key (last_name, first_name, COALESCE(middle_name, ''::text))=(Pushkin,
Alexander, Sergeyevich) already exists.

Page 17: Practice+

  1. Is it possible to create the following objects with the same name that belong to the same schema: 1) a procedure with one input parameter? 2) a function with one input parameter of the same type that returns some value? 3) a procedure with one input and one output parameter? Try and see. 2. A table stores real numbers (for example, measurements of some sort). Create a procedure that performs data normalization by multiplying all numbers by a certain factor, so that all values fit the interval between −1 and 1. The procedure must return the selected normalization factor.

Speaker notes

  1. Take the maximum absolute value from the table as the normalization factor.

Page 18: 1. Procedure and function overloading

=> CREATE DATABASE sql_proc;
CREATE DATABASE
=> \c sql_proc
You are now connected to database "sql_proc" as user "student".

This will not work, because the signature of a routine includes only the name and types of the input parameters (the return value is ignored), and the procedures and functions share a common namespace.

=> CREATE PROCEDURE test(IN x integer)
LANGUAGE sql
RETURN 1;
CREATE PROCEDURE
=> CREATE FUNCTION test(IN x integer) RETURNS integer
LANGUAGE sql
RETURN 1;
ERROR:  function "test" already exists with same argument types

In some messages, as in this one, “function” is used instead of the word “procedure”, because they are similar in many ways.

=> CREATE OR REPLACE PROCEDURE test(IN x integer, OUT y integer)
LANGUAGE sql
RETURN x;
ERROR:  cannot change whether a procedure has output parameters
HINT:  Use DROP PROCEDURE test(integer) first.

This also doesn’t work, since there is already a procedure with the same signature, and it is also forbidden to change the output parameters (and their presence) for an existing routine. The routine must be deleted before recreating it.

2. Data Normalization

Table with test data:

=> CREATE TABLE samples(a float);
CREATE TABLE
=> INSERT INTO samples(a)
    SELECT (0.5 - random())*100 FROM generate_series(1,10);
INSERT 0 10

Just a single SQL statement is enough:

=> CREATE PROCEDURE normalize_samples(INOUT coeff float)
LANGUAGE sql
BEGIN ATOMIC
   WITH c(coeff) AS (
       SELECT 1/max(abs(a))
       FROM samples
   ),
   upd AS (
       UPDATE samples
       SET a = a * c.coeff
       FROM c
   )
   SELECT coeff FROM c;
END;
CREATE PROCEDURE
=> CALL normalize_samples(NULL);
        coeff
----------------------
 0.021707473843965406
(1 row)
=> SELECT * FROM samples;

Page 19: Page 19

          a
----------------------
  -0.5538052060243341
                    1
  -0.7623533652039323
  -0.9010737067043512
  -0.3807898477398218
  0.10349324186333168
    0.504165881570581
   0.8243917598543103
   0.0863366693457439
 -0.15227989829493432
(10 rows)
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE sql_proc;
DROP DATABASE

10 Lesson 10: SQL / Composite Types

Native Markdown source page: SQL / Composite Types
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_10_sql_row.html Archive: DEV1-handouts-16-en.zip PDF pages: 24

Page 1: SQL / Composite Types

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Composite Types and How to Use Them
  • Composite Type Arguments
  • Functions Returning a Single Row
  • Functions Returning a Set of Rows

Page 3: Composite Types

Composite type

  • set of named attributes (fields)
  • same as a table row, but without constraints
  • Creating a composite type
    • explicit declaration of a new type
    • implicitly when a table is created
    • record: a placeholder composite type
  • Using a composite type
    • attributes as scalar values
    • operations on composite type values: comparison, check for NULL,
    • use with subqueries

Speaker notes

A composite type represents a set of attributes, with each attribute having its own name and type. A composite type is similar to a table row in many ways. It is often called a record (similar to a structure in C-like languages).

https://postgrespro.com/docs/postgresql/16/rowtypes

A composite type is a database object. When it is declared, a new type is registered in the system catalog, making it a full-fledged SQL type. A table creation automatically produces a composite type with the same name. This type represents a row of the table. An important difference is that composite types do not have constraints.

https://postgrespro.com/docs/postgresql/16/sql-createtype

Composite type attributes can be used as regular scalar values (although each attribute can also be of a composite type itself).

A composite type can be used just like any other SQL type; for example, you can create table columns of this type. Composite values can be compared, checked for NULL, used with subqueries in clauses like IN, ANY/SOME, ALL.

https://postgrespro.com/docs/postgresql/16/functions-comparisons

https://postgrespro.com/docs/postgresql/16/functions-subquery

Page 4: Explicit Declaration of a Composite Type

The first way to introduce a composite type is to explicitly declare it.

=> CREATE TYPE currency AS (
    amount numeric,
    code   text
);
CREATE TYPE
=> \dT
       List of data types
 Schema |   Name   | Description
--------+----------+-------------
 public | currency |
(1 row)

Such a type can be used just as any other SQL type. For example, we can create a table that has some columns of this type:

=> CREATE TABLE transactions(
    account_id   integer,
    debit        currency,
    credit       currency,
    date_entered date DEFAULT current_date
);
CREATE TABLE

Whether it is a good idea is not an easy question: there are no universal solutions here. In some cases, it can be quite useful; in other situations it’s more convenient to follow the relational data model, i.e., move the entity represented by this type into a separate table and add references to this table. It enables you to avoid data redundancy (normalization) and simplify indexing (a composite type is likely to require an index on expression). In general, PostgreSQL offers quite a lot of built-in data types, so the need for a custom type is unlikely to arise too often.

Constructing Composite Type Values

Composite type values can be constructed in the form of a string, with all the attributes listed in brackets. Note that the attributes of the string type are enclosed in double quotes:

=> INSERT INTO transactions VALUES (1, NULL, '(100.00,"EUR")');
INSERT 0 1

Another option is to use the ROW constructor:

=> INSERT INTO transactions VALUES (2, ROW(80.00,'EUR'), NULL);
INSERT 0 1

If the composite type contains more than one field, you can omit the ROW keyword:

=> INSERT INTO transactions VALUES (3, (20.00,'EUR'), NULL);
INSERT 0 1
=> SELECT * FROM transactions;
 account_id |    debit    |    credit    | date_entered
------------+-------------+--------------+--------------
          1 |             | (100.00,EUR) | 2025-11-27
          2 | (80.00,EUR) |              | 2025-11-27
          3 | (20.00,EUR) |              | 2025-11-27
(3 rows)

Using Composite Type Attributes as Separate Values

Accessing a separate attribute of a composite type is virtually the same operation as accessing a table column, since a table row actually represents a composite type:

=> SELECT t.account_id FROM transactions t;

Page 5: Page 5

 account_id
------------
          1
          2
          3
(3 rows)

In most cases, the composite value has to be enclosed into brackets, e.g., to distinguish between a type attribute and a table column:

=> SELECT (t.debit).amount, (t.credit).amount FROM transactions t;
 amount | amount
--------+--------
        | 100.00
  80.00 |
  20.00 |
(3 rows)

Or if you are using an expression:

=> SELECT ((10.00,'EUR')::currency).amount;
 amount
--------
  10.00
(1 row)

A composite value does not necessarily belong to a particular type, it can be a value of generic record pseudotype:

=> SELECT (10.00,'EUR')::record;
     row
-------------
 (10.00,EUR)
(1 row)

But can you access an attribute of such a value?

=> SELECT ((10.00,'EUR')::record).amount;
ERROR:  could not identify column "amount" in record data type
LINE 1: SELECT ((10.00,'EUR')::record).amount;
                ^

No, because attributes of such a type have no name.

An Implicit Composite Type for Tables

In practice, composite types are typically used to facilitate the use of functions for table processing. When a table is created, a composite type with same name is created implicitly. For example, seats in the cinema:

=> CREATE TABLE seats(
    line text,
    number integer
);
CREATE TABLE
=> INSERT INTO seats VALUES
('A', 42), ('B',  1), ('C', 27);
INSERT 0 3

The \dT command hides such implicit types, but you can take a look at them in the pg_type table if you like:

=> SELECT typtype FROM pg_type WHERE typname = 'seats';
 typtype
---------
 c
(1 row)

Here c stands for a composite type.

Operations on Composite Values

Page 6: Page 6

Composite type values can be compared with each other. It is done element by element (similar to string comparison, which is performed symbol by symbol):

=> SELECT * FROM seats s WHERE s < ('B',52)::seats;
 line | number
------+--------
 A    |     42
 B    |      1
(2 rows)

Beware multiple intricacies of using null values within records. PostgreSQL also supports IS [NOT] NULL and IS [NOT] DISTINCT FROM predicates for composite values. Composite types can be used in subqueries, which happens to be very convenient. Let’s add a table with tickets:

=> CREATE TABLE tickets(
    line text,
    number integer,
    movie_start date
);
CREATE TABLE
=> INSERT INTO tickets VALUES
    ('A', 42, current_date),
    ('B',  1, current_date+1);
INSERT 0 2

Now we can write the following query to search for seats in tickets for a today’s screening:

=> SELECT * FROM seats WHERE (line, number) IN (
    SELECT line, number FROM tickets WHERE movie_start = current_date
);
 line | number
------+--------
 A    |     42
(1 row)

We would have to explicitly join tables if we could not use a subquery.

Page 7: Routine Arguments

A routine can take composite type arguments

  • Implementing computed fields
table.column and column(table) are interchangeable
  • Other options
    • views
    • GENERATED ALWAYS columns

Speaker notes

Naturally, routines can take arguments of composite types.

It’s worth noting that apart from the usual table.column notation, you can access a table column using the functional form: column(table). It allows us to create computed fields by declaring a function that takes a composite value as an argument.

https://postgrespro.com/docs/postgresql/16/xfunc-sql

This approach is a bit odd because there is a more straightforward way to get the same outcome by using a view. The SQL standard also defines GENERATED ALWAYS columns, but their implementation in PostgreSQL does not fully comply with the standard yet: generated columns are STORED in a table instead of being generated on the fly. Such VIRTUAL columns are to be introduced in PostgreSQL 18.

https://postgrespro.com/docs/postgresql/16/ddl-generated-columns

Page 8: Composite Type Parameters

Let’s declare a function that takes a composite value as an input parameter and returns a string with a seat number.

=> CREATE FUNCTION seat_no(seat seats) RETURNS text
IMMUTABLE LANGUAGE sql
RETURN seat.line || seat.number;
CREATE FUNCTION
Note

Note Note that concatenation is stable in general, not immutable: casting some data types to a string can give different results depending on the current settings.

=> SELECT seat_no(ROW('A',42));
 seat_no
---------
 A42
(1 row)

It comes in handy that such functions allow you to pass a table row as an argument:

=> SELECT s.line, s.number, seat_no(s.*) FROM seats s;
 line | number | seat_no
------+--------+---------
 A    |     42 | A42
 B    |      1 | B1
 C    |     27 | C27
(3 rows)

We can also do without an asterisk:

=> SELECT s.line, s.number, seat_no(s) FROM seats s;
 line | number | seat_no
------+--------+---------
 A    |     42 | A42
 B    |      1 | B1
 C    |     27 | C27
(3 rows)

The syntax allows calling a function as if it were a table column (and vice versa, you can access a column as if it were a function):

=> SELECT s.line, number(s), s.seat_no FROM seats s;
 line | number | seat_no
------+--------+---------
 A    |     42 | A42
 B    |      1 | B1
 C    |     27 | C27
(3 rows)

Using this syntax, you can use functions like table columns computed on the fly. What if the table contains a column with the same name? Previously, the column would always have priority; starting from version 11, the choice depends on the syntactic form. Clearly, you can get the same outcome by creating a view.

=> CREATE VIEW seats_v AS
    SELECT s.line, s.number, seat_no(s) FROM seats s;
CREATE VIEW
=> SELECT line, number, seat_no FROM seats_v;
 line | number | seat_no
------+--------+---------
 A    |     42 | A42
 B    |      1 | B1
 C    |     27 | C27
(3 rows)

Page 9: Page 9

And starting from version 12, you can declare “true” computed columns as a part of a table. But instead of being computed on the fly as defined by the SQL standard, they are stored in the table:

=> CREATE TABLE seats2(
    line text,
    number integer,
    seat_no text GENERATED ALWAYS AS (seat_no(ROW(line,number))) STORED
);
CREATE TABLE
=> \d seats2
                                       Table "public.seats2"
 Column  |  Type   | Collation | Nullable |                         Default
---------+---------+-----------+----------+-----------------------------------------------
----------
 line    | text    |           |          |
 number  | integer |           |          |
 seat_no | text    |           |          | generated always as (seat_no(ROW(line,
number))) stored
=> INSERT INTO seats2 (line, number)
    SELECT line, number FROM seats;
INSERT 0 3
=> SELECT * FROM seats2;
 line | number | seat_no
------+--------+---------
 A    |     42 | A42
 B    |      1 | B1
 C    |     27 | C27
(3 rows)

If we later wish to define the value explicitly, we can just drop the expression:

=> ALTER TABLE seats2 ALTER COLUMN seat_no DROP EXPRESSION;
ALTER TABLE

The data is still in the column, but the column is no longer computed.

=> SELECT * FROM seats2;
 line | number | seat_no
------+--------+---------
 A    |     42 | A42
 B    |      1 | B1
 C    |     27 | C27
(3 rows)
=> \d seats2
               Table "public.seats2"
 Column  |  Type   | Collation | Nullable | Default
---------+---------+-----------+----------+---------
 line    | text    |           |          |
 number  | integer |           |          |
 seat_no | text    |           |          |

Page 10: Single Row Functions

Return a composite value

  • Usually called in SELECT list
  • When called within a FROM clause, return a one-row table

Speaker notes

Functions can both take arguments of a composite type and return composite type values.

Functions are usually called in SELECT lists, but it is possible to call a function within a FROM clause, as if it were a one-row table.

Page 11: Functions Returning One Row

Let’s create a function that constructs a table row from separate components and returns it. Such a function can be declared as RETURNS seats:

=> CREATE FUNCTION seat(line text, number integer) RETURNS seats
IMMUTABLE LANGUAGE sql
RETURN ROW(line, number)::seats;
CREATE FUNCTION
=> SELECT seat('A', 42);
  seat
--------
 (A,42)
(1 row)

The returned result is of a composite type. It can be “unfolded” into a one-row table:

=> SELECT (seat('A', 42)).*;
 line | number
------+--------
 A    |     42
(1 row)

Column names and types are received from the definition of the seats composite type here. Apart from calling a function in the SELECT list or as part of an expression, you can also call it in the FROM clause, as if it were a table:

=> SELECT * FROM seat('A', 42);
 line | number
------+--------
 A    |     42
(1 row)

As a result, we are getting a one-row table again. By the way, can we use the same calling method for a function that returns a single (scalar) value?

=> SELECT * FROM abs(-1.5);
 abs
-----
 1.5
(1 row)

Yes, it’s also possible: we get a single column with a single row. Another approach that we have already seen in the SQL. Functions lecture is to define output parameters.

Note

Note Note that you do not have to manually construct the composite type from separate fields in the query; it will be done automatically:

=> DROP FUNCTION seat(text, integer);
DROP FUNCTION
=> CREATE FUNCTION seat(line INOUT text, number INOUT integer)
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC SELECT line, number; END;
CREATE FUNCTION
=> SELECT seat('A', 42);
  seat
--------
 (A,42)
(1 row)
=> SELECT * FROM seat('A', 42);

Page 12: Page 12

 line | number
------+--------
 A    |     42
(1 row)

We get the same outcome, but names and types of the columns are taken from the function input parameters, while the composite type itself remains anonymous. And one more approach is to declare a function that returns the record pseudotype, which denotes a composite type in general, without specifying its structure.

=> DROP FUNCTION seat(text, integer);
DROP FUNCTION
=> CREATE FUNCTION seat(line text, number integer) RETURNS record
IMMUTABLE LANGUAGE sql
RETURN (line, number);
CREATE FUNCTION
=> SELECT seat('A',42);
  seat
--------
 (A,42)
(1 row)

But you won’t be able to call such a function in the FROM clause not just because the return type is anonymous, but also because the number and types of the fields in the returned composite type are not known in advance (at the parsing stage):

=> SELECT * FROM seat('A',42);
ERROR:  a column definition list is required for functions returning "record"
LINE 1: SELECT * FROM seat('A',42);
                      ^

In this case, you have to specify the exact structure of the composite type when calling a function:

=> SELECT * FROM seat('A',42) AS (line text, number integer);
 line | number
------+--------
 A    |     42
(1 row)

You can use any of these three approaches when creating functions. But you should keep in mind the expected use cases from the very beginning: whether it will be convenient to use anonymous types and specify the structure of the type during function calls.

Page 13: Set Returning Functions

Declared as RETURNS SETOF or RETURNS TABLE

  • Can return multiple rows
  • Usually called in a FROM clause
  • Can be used as a view with arguments
    • very convenient when combined with function inlining

Speaker notes

We have tried calling functions in a FROM clause, but have only seen one-row outputs so far. However, there is nothing stopping us from declaring functions that would return a set of rows: the so-called table functions or set returning functions (SRF).

It’s only natural to call these functions in a FROM clause, turning them into a pseudo-views to some extent. (Technically, PostgreSQL allows calling such functions in SELECT lists as well, but it is not recommended.)

Like with regular functions, the planner can sometimes inline the function body into the main query. It allows creating “views with arguments” without additional overhead.

https://wiki.postgresql.org/wiki/Inlining\_of\_SQL\_functions

Page 14: Set Returning Functions

Let’s create a function that returns all seats in a rectangular cinema hall of the specified size.

=> CREATE FUNCTION rect_hall(max_line integer, max_number integer)
RETURNS SETOF seats
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT chr(line+64), number
    FROM generate_series(1,max_line) AS line,
         generate_series(1,max_number) AS number;
END;
CREATE FUNCTION

The key difference is the SETOF usage. In this case, instead of returning the first row of the last query, as usual, the function returns all the rows of the last query.

=> SELECT * FROM rect_hall(max_line => 2, max_number => 3);
 line | number
------+--------
 A    |      1
 A    |      2
 A    |      3
 B    |      1
 B    |      2
 B    |      3
(6 rows)

Instead of SETOF seats you can also use SETOF record:

=> DROP FUNCTION rect_hall(integer, integer);
DROP FUNCTION
=> CREATE FUNCTION rect_hall(max_line integer, max_number integer)
RETURNS SETOF record
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT chr(line+64), number
    FROM generate_series(1,max_line) AS line,
         generate_series(1,max_number) AS number;
END;
CREATE FUNCTION

But as we have already seen, in this case you have to specify the structure of the composite type when calling a function:

=> SELECT * FROM rect_hall(max_line => 2, max_number => 3)
    AS (a_line text, a_number integer);
 a_line | a_number
--------+----------
 A      |        1
 A      |        2
 A      |        3
 B      |        1
 B      |        2
 B      |        3
(6 rows)

Or we could declare a function with output parameters. But SETOF record would still be required to show that the function returns a set of rows, not a single row:

=> DROP FUNCTION rect_hall(integer, integer);
DROP FUNCTION

Page 15: Page 15

=> CREATE FUNCTION rect_hall(
    max_line integer, max_number integer,
    OUT p_line text, OUT p_number integer
)
RETURNS SETOF record
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT chr(line+64), number
    FROM generate_series(1,max_line) AS line,
         generate_series(1,max_number) AS number;
END;
CREATE FUNCTION
=> SELECT * FROM rect_hall(max_line => 2, max_number => 3);
 p_line | p_number
--------+----------
 A      |        1
 A      |        2
 A      |        3
 B      |        1
 B      |        2
 B      |        3
(6 rows)

Another equivalent way to declare a set returning function (which is even defined by the SQL standard) is to use the TABLE keyword:

=> DROP FUNCTION rect_hall(integer, integer);
DROP FUNCTION
=> CREATE FUNCTION rect_hall(max_line integer, max_number integer)
RETURNS TABLE(t_line text, t_number integer)
LANGUAGE sql
BEGIN ATOMIC
    SELECT chr(line+64), number
    FROM generate_series(1,max_line) AS line,
         generate_series(1,max_number) AS number;
END;
CREATE FUNCTION
=> SELECT * FROM rect_hall(max_line => 2, max_number => 3);
 t_line | t_number
--------+----------
 A      |        1
 A      |        2
 A      |        3
 B      |        1
 B      |        2
 B      |        3
(6 rows)

It is sometimes useful to enumerate the rows returned by the query, in the order they were received from the function. There is a special clause for that:

=> SELECT *
FROM rect_hall(max_line => 2, max_number => 3) WITH ORDINALITY;
 t_line | t_number | ordinality
--------+----------+------------
 A      |        1 |          1
 A      |        2 |          2
 A      |        3 |          3
 B      |        1 |          4
 B      |        2 |          5
 B      |        3 |          6
(6 rows)

When a function is used in a FROM clause, the LATERAL keyword is assumed to implicitly precede it, which allows this function to access columns of the tables that were mentioned in the query to the left of the function. It can sometimes simplify query definitions. For example, let’s create a function that distributes seats in the cinema like in an amphitheatre, with front rows having fewer seats than back rows:

Page 16: Page 16

=> CREATE FUNCTION amphitheatre(max_line integer)
RETURNS TABLE(t_line text, t_number integer)
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT chr(line + 64), number
    FROM generate_series(1,max_line) AS line, -- <--+
         generate_series(1, --                      |
                         line ----------------------+
                        ) AS number;
END;
CREATE FUNCTION
=> SELECT * FROM amphitheatre(3);
 t_line | t_number
--------+----------
 A      |        1
 B      |        1
 B      |        2
 C      |        1
 C      |        2
 C      |        3
(6 rows)

It’s interesting that you can call a function returning a set of rows as part of the SELECT list:

=> SELECT rect_hall(3,4);
 rect_hall
-----------
 (A,1)
 (A,2)
 (A,3)
 (A,4)
 (B,1)
 (B,2)
 (B,3)
 (B,4)
 (C,1)
 (C,2)
 (C,3)
 (C,4)
(12 rows)

It seems logical in some cases, but occasionally the result can surprise you. For example, how many rows will be returned by the following query?

=> SELECT rect_hall(2,3), rect_hall(2,2);
 rect_hall | rect_hall
-----------+-----------
 (A,1)     | (A,1)
 (A,2)     | (A,2)
 (A,3)     | (B,1)
 (B,1)     | (B,2)
 (B,2)     |
 (B,3)     |
(6 rows)

We get six rows, while prior to version 10 we would get the least common multiple of the number of rows returned by each function (12 in this case). What’s even worse is that the query can return fewer rows than expected if the function returns no rows when passed some particular parameters. So using this calling method is not recommended.

Functions as Views with Parameters

As we have already seen, a function can be used in the FROM clause, as if it were a table or a view. We can also pass additional parameters in this case, which is sometimes very convenient. The only issue with this approach is that when a function is called (Function Scan), the queries in it must be executed completely first, and additional conditions defined in the query can only be applied after that.

Page 17: Page 17

=> EXPLAIN (costs off)
SELECT * FROM rect_hall(3,4) WHERE t_line = 'A';
           QUERY PLAN
--------------------------------
 Function Scan on rect_hall
   Filter: (t_line = 'A'::text)
(2 rows)

It could become a problem if the function performed a long and complex query. In some cases, a function body can be inlined, i.e., inserted into the calling query. The requirements for set returning functions are more relaxed. The main restrictions are: the function must be written in the SQL language the function itself must not be volatile and must not call other volatile functions the function must not be STRICT the function body must contain only one SELECT statement (although it can introduce a complex query) In this case, we did not specify the volatility category when creating the function, so it was implicitly declared volatile.

=> ALTER FUNCTION rect_hall(integer, integer) IMMUTABLE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT * FROM rect_hall(3,4) WHERE t_line = 'A';
                   QUERY PLAN
------------------------------------------------
 Nested Loop
   ->  Function Scan on generate_series line
         Filter: (chr((line + 64)) = 'A'::text)
   ->  Function Scan on generate_series number
(4 rows)

There is virtually no function call now, and the condition is inserted into the query itself, which is more efficient.

Page 18: Takeaways

Composite types combine values of other types

  • Simplify and enrich function operations on tables
  • Implement computed fields and views with parameters
  • Functions can return multiple rows

Page 19: Practice

  1. Create a function onhand_qty to calculate books in stock. The function takes a composite type parameter (books) and returns an integer number.
  • Use this function in the catalog_v view as a computed field.
  • Verify that the application can now display the number of books. 2. Create a set returning function get_catalog for book search. The function takes values from the search fields (author, book title, in stock) and returns matching books in the catalog_v format.
    • Verify that you can now search for books and browse in the Store
    • tab.

Speaker notes

FUNCTION onhand_qty(book books) RETURNS integer
FUNCTION get_catalog(
 author_name text, book_title text, in_stock boolean
)
RETURNS TABLE(
 book_id integer, display_name text, onhand_qty integer
)

The obvious solution is to use the existing catalog_v view, just with some row filters. But this view displays book titles and authors in the same field, and authors’ names are abbreviated. Clearly, searching for “Reuel” in the “J. R. R. Tolkien” field will yield no results.

The get_catalog function could repeat the query from the catalog_v view, but it is code duplication, which is a bad practice. So you should extend the catalog_v view by adding the following fields: the book title and the full list of authors.

Verify that the empty fields in the form are handled correctly. When calling the get_catalog function, does the client pass empty strings or null values?

Page 20: Task 1. The onhand_qty Function

=> CREATE FUNCTION onhand_qty(book books) RETURNS integer
STABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT coalesce(sum(o.qty_change),0)::integer
    FROM operations o
    WHERE o.book_id = book.book_id;
END;
CREATE FUNCTION
=> CREATE OR REPLACE VIEW catalog_v AS
SELECT b.book_id,
       book_name(b.book_id, b.title) AS display_name,
       b.onhand_qty
FROM   books b
ORDER BY display_name;
CREATE VIEW

Task 2. The get\_catalog Function

Extend the catalog_v view by adding book titles and the full list of authors (the application ignores extra fields). This function returns the full list of authors:

=> CREATE FUNCTION authors(book books) RETURNS text
STABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT string_agg(
               a.first_name ||
               coalesce(' ' || nullif(a.middle_name,''), '') || ' ' ||
               a.last_name,
               ', '
               ORDER BY ash.seq_num
           )
    FROM   authors a
           JOIN authorship ash ON a.author_id = ash.author_id
    WHERE  ash.book_id = book.book_id;
END;
CREATE FUNCTION

Let’s use this function in the catalog_v view. The view already exists, but we will recreate it with a different column order and use a new query:

=> DROP VIEW catalog_v;
DROP VIEW
=> CREATE VIEW catalog_v AS
SELECT b.book_id,
       b.title,
       b.onhand_qty,
       book_name(b.book_id, b.title) AS display_name,
       b.authors
FROM   books b
ORDER BY display_name;
CREATE VIEW

The get_catalog function now uses the extended view:

Page 21: Page 21

=> CREATE FUNCTION get_catalog(
    author_name text,
    book_title text,
    in_stock boolean
)
RETURNS TABLE(book_id integer, display_name text, onhand_qty integer)
STABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT cv.book_id,
           cv.display_name,
           cv.onhand_qty
    FROM   catalog_v cv
    WHERE  cv.title   ILIKE '%'||coalesce(book_title,'')||'%'
    AND    cv.authors ILIKE '%'||coalesce(author_name,'')||'%'
    AND    (in_stock AND cv.onhand_qty > 0 OR in_stock IS NOT TRUE)
    ORDER BY display_name;
END;
CREATE FUNCTION

Page 22: Practice+

  1. Create a function that converts a string representation of a hexadecimal number into a regular integer number. For example: convert(‘FF’) → 255 2. Extend this function with an optional argument that defines the base of a numeral system (16 by default). For example: convert(‘0110’,2) → 6 3. The generate_series set returning function does not work with string types. Create your own function that generates string sequences of uppercase Latin letters.

Speaker notes

  1. For example:
convert('FF') → 255

To solve this problem, you can use a regexp_split_to_table set returning function, upper and reverse functions, and WITH ORDINALITY clause.

Another option is to use a recursive query.

You can check the implementation using hexadecimal constants:

SELECT X'FF'::integer;
  1. For example:
convert('0110',2) → 6
  1. Assume that the input strings have the same length. For example:
generate_series('AA','ZZ') →
 →
'AA'
'AB'
'AC'
...
'ZY'
'ZZ'

Page 23: 1. A Hexadecimal System Function

=> CREATE DATABASE sql_row;
CREATE DATABASE
=> \c sql_row
You are now connected to database "sql_row" as user "student".

Define a function for a single digit first:

=> CREATE FUNCTION digit(d text) RETURNS integer
IMMUTABLE LANGUAGE sql
RETURN ascii(d) - CASE
         WHEN d BETWEEN '0' AND '9' THEN ascii('0')
         ELSE ascii('A') - 10
       END;
CREATE FUNCTION

Now, the main function:

=> CREATE FUNCTION convert(hex text) RETURNS integer
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
  WITH s(d,ord) AS (
    SELECT *
    FROM regexp_split_to_table(reverse(upper(hex)),'') WITH ORDINALITY
  )
  SELECT sum(digit(d) * 16^(ord-1))::integer FROM s;
END;
CREATE FUNCTION
=> SELECT convert('0FE'), convert('0FF'), convert('100');
 convert | convert | convert
---------+---------+---------
     254 |     255 |     256
(1 row)

2. A Function for Any Base

Assume the base is from 2 to 36: a digit may be a number from 0 to 9 or a letter from A to Z. In this case, the changes are minimal.

=> DROP FUNCTION convert(text);
DROP FUNCTION
=> CREATE FUNCTION convert(num text, radix integer DEFAULT 16) RETURNS integer
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
  WITH s(d,ord) AS (
    SELECT *
    FROM regexp_split_to_table(reverse(upper(num)),'') WITH ORDINALITY
  )
  SELECT sum(digit(d) * radix^(ord-1))::integer FROM s;
END;
CREATE FUNCTION
=> SELECT convert('101100', 2), convert('2C'), convert('54', 8);
 convert | convert | convert
---------+---------+---------
      44 |      44 |      44
(1 row)
Note

Note Note that in PostgreSQL 16 and onwards, integer constants can be not only decimal, but also binary, hexadecimal, and octal. This feature comes from the modern SQL standard:

=> SELECT 0b101100 AS bin, 0x2C AS hex, 0o54 AS oct;

Page 24: 3. The generate_series Function for Strings

 bin | hex | oct
-----+-----+-----
  44 |  44 |  44
(1 row)

First, create two auxiliary functions that convert a string to a numeric representation and vice versa. The first one is very similar to the function from the previous assignment:

=> CREATE FUNCTION text2num(s text) RETURNS integer
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
  WITH s(d,ord) AS (
    SELECT *
    FROM regexp_split_to_table(reverse(s),'') WITH ORDINALITY
  )
  SELECT sum( (ascii(d)-ascii('A')) * 26^(ord-1))::integer FROM s;
END;
CREATE FUNCTION

The inverse function will use a recursive query:

=> CREATE FUNCTION num2text(n integer, digits integer) RETURNS text
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
  WITH RECURSIVE r(num,txt, level) AS (
    SELECT n/26, chr( n%26 + ascii('A') )::text, 1
    UNION ALL
    SELECT r.num/26, chr( r.num%26 + ascii('A') ) || r.txt, r.level+1
    FROM r
    WHERE r.level < digits
  )
  SELECT r.txt FROM r WHERE r.level = digits;
END;
CREATE FUNCTION
=> SELECT num2text( text2num('ABC'), length('ABC') );
 num2text
----------
 ABC
(1 row)

Now, create generate_series for strings using generate_series for integers.

=> CREATE FUNCTION generate_series(start text, stop text) RETURNS SETOF text
IMMUTABLE LANGUAGE sql
BEGIN ATOMIC
  SELECT num2text( g.n, length(start)) FROM generate_series(text2num(start), text2num(stop)) g(n);
END;
CREATE FUNCTION
=> SELECT generate_series('AZ','BC');
 generate_series
-----------------
 AZ
 BA
 BB
 BC
(4 rows)
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE sql_row;
DROP DATABASE

11 Lesson 11: PL/pgSQL / Overview and Programming Structures

Native Markdown source page: PL/pgSQL / Overview and Programming Structures
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_11_plpgsql_introduction.html Archive: DEV1-handouts-16-en.zip PDF pages: 25

Page 1: PL/pgSQL / Overview and Programming Structures

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • PL/pgSQL History
  • Block Structure and Declaration of Variables
  • Anonymous Blocks
  • Routines in PL/pgSQL
  • Conditional Statements and Loops
  • Expression Computing

Page 3: PL/pgSQL History

Introduced in PostgreSQL 6.4 in 1998

  • comes out of the box since PostgreSQL 9.0
  • Objectives
    • create a simple language for custom functions and triggers
    • add control structures to the SQL language
    • keep the ability to use any custom types, functions, and statements
  • Inspired by: Oracle PL/SQL, Ada

Speaker notes

PL/pgSQL is one of the first procedural languages for PostgreSQL. It first appeared in 1998 in PostgreSQL 6.4, and since 9.0, it has been installed by default when a database is created.

PL/pgSQL extends the SQL functionality, providing variables and cursors, conditional statements, loops, error handling, and other features commonly seen in procedural languages.

PL/pgSQL is based on the Oracle PL/SQL language, which, in turn, is derived from a subset of the Ada language, with its roots going back to Algol and Pascal. Most of the modern programming languages belong to another branch of the C-like languages, that’s why PL/pgSQL can at first seem unconventional and excessively verbose (its distinctive feature is using BEGIN and END keywords instead of curly brackets). However, this syntax goes perfectly with the SQL syntax.

https://postgrespro.com/docs/postgresql/16/plpgsql-overview

Page 4: Block Structure

Block label

  • Variable declarations
    • variable scope is a block
    • the name can be overridden by a nested block,
    • but a variable can still be qualified by a block label
    • any SQL types and references to object types (%TYPE) are allowed
  • Statements
    • control structures
    • SQL statements, except for the service ones
    • nested blocks
  • Exception handling

Speaker notes

PL/pgSQL statements are organized into blocks. A block structure comprises several components:

● An optional label that can be used to eliminate naming ambiguities.

● An optional section for declaration of local variables and cursors. Any types defined in SQL are allowed. You can also use the %TYPE construct to refer to the type of a table column or other object.

● The main execution section that contains statements.

● An optional section for handling exceptions.

You can use both PL/pgSQL commands and most of SQL commands as statements, so the two languages are integrated almost seamlessly. Exceptions are SQL service commands, such as VACUUM, which are not allowed, and transaction control commands, such as COMMIT and ROLLBACK, which are allowed only in procedures.

Another (nested) PL/pgSQL block can also be used as a statement.

https://postgrespro.com/docs/postgresql/16/plpgsql-structure

https://postgrespro.com/docs/postgresql/16/plpgsql-declarations#PLPGSQL -DECLARATION-TYPE

Page 5: Anonymous Blocks

Ad-hoc execution of procedures

  • without creating a stored routine
  • with no parameters
  • with no return values
  • The DO command in the SQL language

Speaker notes

You can use PL/pgSQL without creating routines. The PL/pgSQL code can be written as an anonymous block and executed using the SQL’s DO command.

This command can be used with various server languages, but if you do not specify the language explicitly, it will be assumed that PL/pgSQL is used.

The code of anonymous blocks is not saved on the server. Anonymous blocks do not take arguments or return any values (but there are ways to circumvent that: for example, by using tables).

https://postgrespro.com/docs/postgresql/16/sql-do

Page 6: Anonymous Blocks

A general structure of a PL/pgSQL block:

<<label>>
DECLARE
    -- declarations of variables
BEGIN
    -- statements
EXCEPTION
    -- error handling
END label;

All sections except for the statements section are optional. The smallest block of PL/pgSQL code:

=> DO $$
BEGIN
    -- there may be no statements
END
$$;
DO

One of the implementations of “Hello, World!”:

=> DO $$
DECLARE
    -- This is a single-line comment.
    /* This is a multiline comment.
       Each declaration is ended by a semicolon ';'.
       A semicolon is also placed after each statement.
    */
    foo text;
    bar text := 'World'; -- you can also use = or DEFAULT
BEGIN
    foo := 'Hello'; -- this is an assignment operation
    RAISE NOTICE '%, %!', foo, bar; -- message output
END
$$;
NOTICE:  Hello, World!
DO

There must be no semicolon after BEGIN! Variables can have modifiers: CONSTANT — once a variable is initialized, its value must not change; NOT NULL — undefined values are not allowed.

=> DO $$
DECLARE
    foo integer NOT NULL := 0;
    bar CONSTANT text := 42;
BEGIN
    bar := bar + 1; -- error
END
$$;
ERROR:  variable "bar" is declared CONSTANT
LINE 6:     bar := bar + 1; -- error
            ^

Here is an example of nested blocks. A variable in the inner block overrides the one declared in the outer block, but you can refer to any of them using labels:

Page 7: Page 7

=> DO $$
<<outer_block>>
DECLARE
    foo text := 'Hello';
BEGIN
    <<inner_block>>
    DECLARE
        foo text := 'World';
    BEGIN
        RAISE NOTICE '%, %!', outer_block.foo, inner_block.foo;
        RAISE NOTICE 'An inner variable, without a label: %', foo;
    END inner_block;
END outer_block
$$;
NOTICE:  Hello, World!
NOTICE:  An inner variable, without a label: World
DO

Page 8: PL/pgSQL Routines

A routine header is language-agnostic

  • name, input and output parameters
  • for functions: the return value and volatility category
  • LANGUAGE plpgsql clause
  • Returning values
    • RETURN statement
    • assigning values to output parameters (INOUT, OUT)

Speaker notes

We have already learned about stored functions and procedures, using the SQL language as an example. Most of the information related to creation and management of routines applies to PL/pgSQL routines as well:

● Creating, modifying, and deleting routines

● Location in the system catalog (pg_proc)

● Parameters

● Return value and volatility categories (for functions)

● Overloading and polymorphism

● Etc.

While SQL routines return a value produced by the last SQL command, PL/pgSQL routines either have to assign return values to INOUT or OUT parameters, or use a special RETURN statement (which is available for functions).

Page 9: PL/pgSQL Routines

Here is an example of a function that returns a value using the RETURN statement:

=> CREATE FUNCTION sqr_in(IN a numeric) RETURNS numeric
AS $$
BEGIN
    RETURN a * a;
END
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Now let’s take a look at the same function with the OUT parameter. The return value is assigned to this parameter:

=> CREATE FUNCTION sqr_out(IN a numeric, OUT retval numeric)
AS $$
BEGIN
    retval := a * a;
END
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Here is the same function with the INOUT parameter. This parameter is used for both providing input values and returning the function value:

=> CREATE FUNCTION sqr_inout(INOUT a numeric)
AS $$
BEGIN
    a := a * a;
END
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION
=> SELECT sqr_in(3), sqr_out(3), sqr_inout(3);
 sqr_in | sqr_out | sqr_inout
--------+---------+-----------
      9 |       9 |         9
(1 row)

Page 10: Conditional Statements

IF

  • a regular conditional statement
  • CASE
    • similar to CASE in the SQL language, but does not return a value
Note

Note: three-valued logic!

  • the condition must be true; false and NULL are ignored

Speaker notes

PL/pgSQL provides two conditional statements: IF and CASE.

The first one is the bread and butter statement available in all languages.

The second one is similar to the SQL CASE construct, but it is a statement and so it does not return a value. It is unlike the switch statements in C or Java.

Remember that boolean expressions in SQL (and, consequently, in PL/pgSQL) can take three values: true, false, and NULL. A condition is triggered only when it is true, and is not triggered when it is false or undefined. This is equally applicable to both WHERE conditions in SQL and conditional statements in PL/pgSQL.

https://postgrespro.com/docs/postgresql/16/plpgsql-control-structures#PLP GSQL-CONDITIONALS

Page 11: Conditional Statements

A generic form of the IF statement:

IF condition THEN
    -- statements
ELSIF condition THEN
    -- statements
ELSE
    -- statements
END IF;

The ELSIF section can be used several times, or there can be no such section at all. There can be no ELSE section. The statements corresponding to the first true condition will be executed. If none of the conditions is true, the statements of the ELSE section are executed (if available). Consider an example of a function that uses a conditional statement for decoding an ISBN-10 number. The function returns three values:

=> CREATE FUNCTION decode_isbn(
    IN isbn text,
    OUT country text,
    OUT publisher_and_book text,
    OUT check_digit integer
) AS $$
DECLARE
    country_len integer;
BEGIN
    IF left(isbn,1)::integer IN (0,1,2,3,4,5,7) THEN
        country_len := 1;
    ELSIF left(isbn,2)::integer BETWEEN 80 AND 94 THEN
        country_len := 2;
    ELSIF left(isbn,3)::integer BETWEEN 600 AND 649 THEN
        country_len := 3;
    ELSIF left(isbn,3)::integer BETWEEN 950 AND 993 THEN
        country_len := 3;
    ELSIF left(isbn,4)::integer BETWEEN 9940 AND 9989 THEN
        country_len := 4;
    ELSE
        country_len := 5;
    END IF;
    country := left(isbn, country_len);
    publisher_and_book := substr(isbn, country_len+1, 12);
    check_digit := right(isbn, 1);
END
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION
=> SELECT * FROM decode_isbn('1484268849');
 country | publisher_and_book | check_digit
---------+--------------------+-------------
 1       | 484268849          |           9
(1 row)
=> SELECT * FROM decode_isbn('8845210669');
 country | publisher_and_book | check_digit
---------+--------------------+-------------
 88      | 45210669           |           9
(1 row)

A generic form of the CASE statement (by condition):

CASE
    WHEN condition THEN
        -- statements
    ELSE
        -- statements
END CASE;

Page 12: Page 12

There can be several WHEN sections. There can be no ELSE section. The statements corresponding to the first true condition will be executed. If none of the conditions is true, ELSE statements are executed (it is an error to have no ELSE in this case). Usage example:

=> DO $$
DECLARE
    country text := (decode_isbn('1484268849')).country;
BEGIN
    CASE
        WHEN country IN ('0','1') THEN
            RAISE NOTICE '% — English-speaking area', country;
        WHEN country = '7' THEN
            RAISE NOTICE '% — Russia', country;
        WHEN country = '88' THEN
            RAISE NOTICE '% — Italy', country;
        ELSE
            RAISE NOTICE '% — Other', country;
    END CASE;
END
$$;
NOTICE:  1 — English-speaking area
DO

A generic form of the CASE statement (by expression):

CASE expression
    WHEN value, ... THEN
        -- statements
    ELSE
        -- statements
END CASE;

There can be several WHEN sections. There can be no ELSE section. The statements corresponding to the first true condition “expression = value” will be executed. If none of the conditions is true, ELSE statements are executed (it is an error to have no ELSE in this case). If conditions are similar, this form of the CASE statement can turn out to be shorter:

=> DO $$
DECLARE
    country text := (decode_isbn('8845210669')).country;
BEGIN
    CASE country
        WHEN '0', '1' THEN
            RAISE NOTICE '% — English-speaking area', country;
        WHEN '7' THEN
            RAISE NOTICE '% — Russia', country;
        WHEN '88' THEN
            RAISE NOTICE '% — Italy', country;
        ELSE
            RAISE NOTICE '% — Other', country;
    END CASE;
END
$$;
NOTICE:  88 — Italy
DO

Page 13: Loops

FOR loop over a range of integers

  • WHILE loop with a precondition
  • Infinite loop
  • Loop can have its own label, just like any block
  • Loop controls
    • exit a loop (EXIT)
    • begin a new iteration (CONTINUE)

Speaker notes

For repeated execution of a set of statements, PL/pgSQL offers several types of loops:

● A FOR loop over a range of integers

● A WHILE loop with a precondition

● An infinite loop

A loop is a type of a block; it can have its own label. You can additionally control loop execution by initiating a new iteration or exiting the loop.

https://postgrespro.com/docs/postgresql/16/plpgsql-control-structures#PLP GSQL-CONTROL-STRUCTURES-LOOPS

In addition to working with integer ranges, FOR loops can iterate through query results and arrays. Such FOR loops will be discussed later on.

Page 14: Loops

In PL/pgSQL, all loops have the same structure:

LOOP
    -- statements
END LOOP;

It can be extended by a header that defines the exit condition for the loop. A FOR loop over a range is executed while the loop counter goes over the values from bottom to top. Each iteration increases the counter by 1 (but the increment can be changed in the optional BY clause).

FOR name IN bottom .. top BY increment
LOOP
    -- statements
END LOOP;

The variable used as a counter is declared implicitly and exists only within the LOOP — END LOOP block. If REVERSE is specified, the counter value is reduced with each iteration, and the top and bottom of the loop have to be swapped:

FOR name IN REVERSE top .. bottom BY increment
LOOP
    -- statements
END LOOP;

An example of using a FOR loop is a function that reverses a string:

=> CREATE FUNCTION reverse_for (line text) RETURNS text
AS $$
DECLARE
    line_length CONSTANT int := length(line);
    retval text := '';
BEGIN
    FOR i IN 1 .. line_length
    LOOP
        retval := substr(line, i, 1) || retval;
    END LOOP;
    RETURN retval;
END
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
CREATE FUNCTION

As you might remember, a STRICT function returns NULL right away if at least one of the input parameters is undefined. The function body is not executed in this case. A WHILE loop is executed while the condition is true:

WHILE condition
LOOP
    -- statements
END LOOP;

Here is the same function that reverses a string using a WHILE loop:

=> CREATE FUNCTION reverse_while (line text) RETURNS text
AS $$
DECLARE
    line_length CONSTANT int := length(line);
    i int := 1;
    retval text := '';
BEGIN
    WHILE i <= line_length
    LOOP
        retval := substr(line, i, 1) || retval;
        i := i + 1;
    END LOOP;
    RETURN retval;
END
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
CREATE FUNCTION

Page 15: Page 15

A LOOP without a header runs infinitely. To terminate it, use the EXIT statement.

EXIT label WHEN condition;

The label is optional; if it is not specified, the innermost loop will be terminated. The WHEN condition is also optional; if it is not specified, the loop is exited unconditionally. LOOP usage example:

=> CREATE FUNCTION reverse_loop (line text) RETURNS text
AS $$
DECLARE
    line_length CONSTANT int := length(reverse_loop.line);
    i int := 1;
    retval text := '';
BEGIN
    <<main_loop>>
    LOOP
        EXIT main_loop WHEN i > line_length;
        retval := substr(reverse_loop.line, i,1) || retval;
        i := i + 1;
    END LOOP;
    RETURN retval;
END
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
CREATE FUNCTION

The function body is placed into an implicit block, with the function name used as the block label. So you can access parameters using the “function_name.parameter” notation. Let’s make sure that all functions work correctly:

=> SELECT reverse_for('AMBULANCE') as "for",
          reverse_while('AMBULANCE') as "while",
          reverse_loop('AMBULANCE') as "loop";
    for    |   while   |   loop
-----------+-----------+-----------
 ECNALUBMA | ECNALUBMA | ECNALUBMA
(1 row)
Note

Note: PostgreSQL has a built-in reverse function. It is sometimes useful to apply the CONTINUE statement, which starts a new iteration of the loop:

=> DO $$
DECLARE
    s integer := 0;
BEGIN
    FOR i IN 1 .. 100
    LOOP
        s := s + i;
        CONTINUE WHEN mod(i, 10) != 0;
        RAISE NOTICE 'i = %, s = %', i, s;
    END LOOP;
END
$$;
NOTICE:  i = 10, s = 55
NOTICE:  i = 20, s = 210
NOTICE:  i = 30, s = 465
NOTICE:  i = 40, s = 820
NOTICE:  i = 50, s = 1275
NOTICE:  i = 60, s = 1830
NOTICE:  i = 70, s = 2485
NOTICE:  i = 80, s = 3240
NOTICE:  i = 90, s = 4095
NOTICE:  i = 100, s = 5050
DO

Page 16: Expression Computing

Any expression is computed in the context of SQL

  • an expression is automatically converted into a query
  • the query is prepared
  • PL/pgSQL variables are substituted as parameters
  • Features
    • you can use all SQL capabilities, including subqueries
    • the execution speed is lower
    • although the parsed query (and sometimes the query plan) is cached
    • naming ambiguities are an issue

Speaker notes

All expressions in PL/pgSQL code are computed as SQL database queries. The interpreter builds the query by composing a SELECT statement and putting parameters in place of PL/pgSQL variables. Next, the statement is prepared (parsed query is cached as usually) and planned. When the statement is executed, specific values are bound to the parameters, and planning is redone (if PostgreSQL has the query plan cached as well, this step may be skipped).

While executing SQL queries impacts PL/pgSQL performance, it ensures close integration with SQL. In fact, expressions can leverage any SQL functionality without limitations, including calling built-in or custom functions, running subqueries, etc.

Starting with PostgreSQL 14, the execution of simple expressions (at least those that do not access any tables) has been optimized: such expressions are processed by the server’s parser directly, without using the planner at all.

https://postgrespro.com/docs/postgresql/16/plpgsql-expressions

Page 17: Computing Expressions

Any PL/pgSQL expression is computed using the SQL engine. Thus, PL/pgSQL provides exactly the same features as SQL. For example, since SQL allows using CASE, the same construct will also work in PL/pgSQL code (as an expression; it should not be confused with the CASE … END CASE statement, which is available only in PL/pgSQL):

=> DO $$
BEGIN
    RAISE NOTICE '%', CASE 2+2 WHEN 4 THEN 'Everything is OK' END;
END
$$;
NOTICE:  Everything is OK
DO

You can also use subqueries in expressions:

=> DO $$
BEGIN
    RAISE NOTICE '%', (
        SELECT code
        FROM (VALUES (1, 'One'), (2, 'Two')) t(id, code)
        WHERE id = 1
    );
END
$$;
NOTICE:  One
DO

Another PL/pgSQL expression computation example: how many string reverse functions did we have in total?

=> DO $$
DECLARE
  s integer;
BEGIN
  s := count(*) FROM pg_proc WHERE proname LIKE 'reverse%';
  RAISE NOTICE 'Total "reverse" functions : %', s;
END
$$;
NOTICE:  Total "reverse" functions : 4
DO

Page 18: Takeaways

PL/pgSQL is an easy-to-use language that comes with the system by default, integrated with SQL

  • Managing routines in PL/pgSQL is similar to other languages
  • DO is an SQL command for executing anonymous blocks
  • PL/pgSQL variables can use any SQL types
  • PL/pgSQL supports regular control structures, such as conditional statements and loops

Page 19: Practice

  1. Modify the book_name function, so that the length of the return value does not exceed 47 characters. If the book title gets truncated, it must be concluded with an ellipsis.
  • Check your implementation in SQL and in the application. Add more books with long titles if required. 2. Modify the book_name function again, so that an excessively long title gets cut off at the end of a full word.
    • Check the implementation.

Speaker notes

  1. For example:
Travels into Several Remote Nations of the World. In Four Parts.
By Lemuel Gulliver, First a Surgeon, and then a Captain of Several
Ships →
→ Travels into Several Remote Nations of the W...

Here are some cases that are worth checking for:

● The title length is less than 47 characters (should not change).

● The title length is exactly 47 characters (should not change).

● The title length is 48 characters (four characters have to be truncated because three dots will be added).

It is recommended to implement and debug a separate function for truncation, and then use it in book_name. It is useful for other reasons as well:

● It may come in handy somewhere else.

● Each function will perform exactly one task.

  1. For example:
Travels into Several Remote Nations of the World. In Four Parts.
By Lemuel Gulliver, First a Surgeon, and then a Captain of Several
Ships →
→ Travels into Several Remote Nations of the...

Will your implementation work properly if the title consists of a single long word without spaces?

Page 20: 1. Truncating Book Titles

Let’s create a more general function that accepts the following parameters: the string to truncate, the maximum length, and the suffix to be used in case of truncation. This does not complicate the code and avoids magic numbers.

=> CREATE FUNCTION shorten(
    s text,
    max_len integer DEFAULT 47,
    suffix text DEFAULT '...'
)
RETURNS text AS $$
DECLARE
    suffix_len integer := length(suffix);
BEGIN
    RETURN CASE WHEN length(s) > max_len
        THEN left(s, max_len - suffix_len) || suffix
        ELSE s
    END;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

Check the result:

=> SELECT shorten(
    'Travels into Several Remote Nations of the World. In Four Parts. '
'By Lemuel Gulliver, First a Surgeon, and then a Captain of Several Ships'
);
                     shorten
-------------------------------------------------
 Travels into Several Remote Nations of the W...
(1 row)
=> SELECT shorten(
    'Travels into Several Remote Nations of the World. In Four Parts. '
'By Lemuel Gulliver, First a Surgeon, and then a Captain of Several Ships',
    34
);
              shorten
------------------------------------
 Travels into Several Remote Nat...
(1 row)

Let’s use the created function:

=> CREATE OR REPLACE FUNCTION book_name(book_id integer, title text)
RETURNS text
STABLE LANGUAGE sql
BEGIN ATOMIC
SELECT shorten(book_name.title) ||
       CASE WHEN (right(shorten(book_name.title), 3) != '...')
           THEN '. '::text
           ELSE ' '
       END ||
       string_agg(
           author_name(a.last_name, a.first_name, a.middle_name), ', '
           ORDER BY ash.seq_num
       )
FROM   authors a
       JOIN authorship ash ON a.author_id = ash.author_id
WHERE  ash.book_id = book_name.book_id;
END;
CREATE FUNCTION

2. Truncating Book Titles by Full Words

Page 21: Page 21

=> CREATE OR REPLACE FUNCTION shorten(
    s text,
    max_len integer DEFAULT 47,
    suffix text DEFAULT '...'
)
RETURNS text
AS $$
DECLARE
    suffix_len integer := length(suffix);
    short text := suffix;
BEGIN
    IF length(s) < max_len THEN
        RETURN s;
    END IF;
    FOR pos in 1 .. least(max_len-suffix_len+1, length(s))
    LOOP
        IF substr(s,pos-1,1) != ' ' AND substr(s,pos,1) = ' ' THEN
            short := left(s, pos-1) || suffix;
        END IF;
    END LOOP;
    RETURN short;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

Check the result:

=> SELECT shorten(
    'Travels into Several Remote Nations of the World. In Four Parts. '
'By Lemuel Gulliver, First a Surgeon, and then a Captain of Several Ships'
);
                    shorten
-----------------------------------------------
 Travels into Several Remote Nations of the...
(1 row)
=> SELECT shorten(
    'Travels into Several Remote Nations of the World. In Four Parts. '
'By Lemuel Gulliver, First a Surgeon, and then a Captain of Several Ships',
    34
);
            shorten
--------------------------------
 Travels into Several Remote...
(1 row)

Page 22: Practice+

  1. Create a PL/pgSQL function that returns a string of random characters of the specified length. 2. The Monty Hall problem.
  • There are three doors. Behind one of the doors is a prize.
  • The player selects one of the doors. The game host opens one
  • of the two remaining doors that does not contain the prize, and
  • gives the player an opportunity to change the choice — that is,
  • to select the other door from the remaining two.
  • Should the player switch the choice, or is it better to stick with
  • the initial one?
  • Task: using PL/pgSQL, calculate the probability of winning for
  • both situations: keeping the initial choice and switching doors.

Speaker notes

You can first create the rnd_integer function that returns a random integer within the specified range. You can use the function for both problems.

For example: rnd_integer(30, 1000) → 616
  1. In addition to specifying the string length, you can also provide the list of allowed characters as an argument. By default, the list can be all Latin letters, digits, and common special characters. To select random characters from the list, you can use the rnd_integer function. The function declaration can look as follows:
CREATE FUNCTION rnd_text(
 len int,
 list_of_chars text DEFAULT
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_0123456789'
) RETURNS text AS ...
Function call example: rnd_text(10) → 'LjdabF_OОJ'
  1. You can use an anonymous block for this one.

First, implement one round of the game and see which option won: the initial one or the modified one. You can use rnd_integer(1,3) for setting and guessing the winning door.

Then run the game in a loop for a thousand times or so, counting wins for each strategy. Finally, use RAISE NOTICE to display the counter values and determine the optimal choice (or lack thereof).

Page 23: 1. A Random String of a Given Size

=> CREATE DATABASE plpgsql_introduction;
CREATE DATABASE
=> \c plpgsql_introduction
You are now connected to database "plpgsql_introduction" as user "student".

First, define an auxiliary function to obtain a random integer within a given range. Such a function is trivial in pure SQL, but here is a PL/pgSQL version instead:

=> CREATE FUNCTION rnd_integer(min_value integer, max_value integer)
RETURNS integer
AS $$
DECLARE
    retval integer;
BEGIN
    IF max_value <= min_value THEN
       RETURN NULL;
    END IF;
    retval := floor(
            (max_value+1 - min_value)*random()
)::integer + min_value;
    RETURN retval;
END
$$ STRICT LANGUAGE plpgsql;
CREATE FUNCTION

Test it:

=> SELECT rnd_integer(0,1) as "0 - 1",
          rnd_integer(1,365) as "1 - 365",
          rnd_integer(-30,30) as "-30 - +30"
   FROM generate_series(1,10);
 0 - 1 | 1 - 365 | -30 - +30
-------+---------+-----------
     1 |     316 |       -22
     1 |     269 |       -11
     0 |      15 |        27
     0 |     118 |        13
     0 |     142 |        26
     1 |     153 |       -11
     1 |     316 |       -12
     1 |     235 |        29
     0 |      83 |        29
     0 |      94 |        24
(10 rows)

The function ensures an even distribution of random values over the entire range, including boundaries:

=> SELECT rnd_value, count(*)
FROM (
    SELECT rnd_integer(1,5) AS rnd_value
    FROM generate_series(1,100_000)
)
GROUP BY rnd_value ORDER BY rnd_value;
 rnd_value | count
-----------+-------
         1 | 19697
         2 | 20007
         3 | 20058
         4 | 20227
         5 | 20011
(5 rows)

Now, start working on a function to obtain a random string of a given size. We will use the rnd_integer function to get a random character from the list.

Page 24: Page 24

=> CREATE FUNCTION rnd_text(
   len int,
   list_of_chars text DEFAULT 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_0123456789'
) RETURNS text
AS $$
DECLARE
    len_of_list CONSTANT integer := length(list_of_chars);
    i integer;
    retval text := '';
BEGIN
    FOR i IN 1 .. len
    LOOP
        -- add a random character to the string
        retval := retval ||
                  substr(list_of_chars, rnd_integer(1,len_of_list),1);
    END LOOP;
    RETURN retval;
END
$$ STRICT LANGUAGE plpgsql;
CREATE FUNCTION

Test it:

=> SELECT rnd_text(rnd_integer(1,30)) FROM generate_series(1,10);
          rnd_text
-----------------------------
 x6N8vbAZYNIDfvjP
 GTb2tmPD2b
 MV73VeLiJ5YRkwqNlxl0C3NgBMm
 xUZnTgBFko5HLIJeVxKGZnK4oB1
 7EC
 XtlsGPVCP9_teegBrsN_nEpvI3
 ClQYO70vZ58gOq67y2KY
 yKKq8FuSCOr7xO
 hRmi3z3yVUfZKde
 s5BhEGhhr7gWEAYliM4xGg
(10 rows)

2. The Monty Hall Problem

To set and guess the door, use rnd_integer(1,3).

Page 25: Page 25

=> DO $$
DECLARE
    x integer;
    choice integer;
    new_choice integer;
    remove integer;
    total_games integer := 1000;
    old_choice_win_counter integer := 0;
    new_choice_win_counter integer := 0;
BEGIN
    FOR i IN 1 .. total_games
    LOOP
        -- Set the prize door
        x := rnd_integer(1,3);
        -- Player makes a choice
        choice := rnd_integer(1,3);
        -- Remove one wrong door not selected by the player
        FOR i IN 1 .. 3
        LOOP
            IF i NOT IN (x, choice) THEN
                remove := i;
                EXIT;
            END IF;
        END LOOP;
        -- Should the player switch their choice?
        -- Is it better to stay or to switch?
        -- Switch
        FOR i IN 1 .. 3
        LOOP
            IF i NOT IN (remove, choice) THEN
                new_choice := i;
                EXIT;
            END IF;
        END LOOP;
        -- Either the original or the new choice always wins
        IF choice = x THEN
            old_choice_win_counter := old_choice_win_counter + 1;
        ELSIF new_choice = x THEN
            new_choice_win_counter := new_choice_win_counter + 1;
        END IF;
    END LOOP;
    RAISE NOTICE 'The original wins:  % out of %',
        old_choice_win_counter, total_games;
    RAISE NOTICE 'The switch wins: % out of %',
        new_choice_win_counter, total_games;
END
$$;
NOTICE:  The original wins:  325 out of 1000
NOTICE:  The switch wins: 675 out of 1000
DO

Initially, we select 1 out of 3, so the chance to win is 1/3. If we switch, the probability switches to the opposite, 2/3. Therefore, the chance to win doubles when we switch.

=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_introduction;
DROP DATABASE

12 Lesson 12: PL/pgSQL / Query Execution

Native Markdown source page: PL/pgSQL / Query Execution
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_12_plpgsql_queries.html Archive: DEV1-handouts-16-en.zip PDF pages: 24

Page 1: PL/pgSQL / Query Execution

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Using SQL Commands in PL/pgSQL Code
  • Eliminating Naming Ambiguities
  • Checking Command Status
  • Set Returning Functions

Page 3: Returning Nothing

SQL commands are embedded into PL/pgSQL code

  • same as in expressions:
  • the statement is prepared, PL/pgSQL variables become parameters
  • SELECT → PERFORM
    • convenient for calling functions with side effects
    • WITH queries should be wrapped into a SELECT
  • INSERT, UPDATE, DELETE and other SQL commands
    • except for service commands
    • transaction management: only in procedures and anonymous blocks

Speaker notes

As we have already seen, PL/pgSQL is very closely integrated with SQL. In particular, all expressions are computed using prepared SQL statements. Besides, expressions can use PL/pgSQL variables and routine parameters: they will be implicitly converted to query parameters.

SQL statements can also be executed within PL/pgSQL code. To execute a statement that returns no result (INSERT, UPDATE, DELETE, CREATE, DROP, etc.), you just need to write an SQL command within the PL/pgSQL code as a separate statement.

Commands are prepared exactly like expressions, with PL/pgSQL variables used as parameters. It allows caching the parsed (or planned) query to avoid repeating this work.

In a similar manner, you can also execute a regular SELECT statement if its result is unimportant; simply replace the SELECT keyword with PERFORM. It makes sense for cases like calling functions with side effects. WITH queries have to be wrapped into a SELECT statement (so that it starts with PERFORM when executed).

Service commands like VACUUM, REINDEX and others are not allowed in routines.

COMMIT and ROLLBACK commands are allowed only in procedures and anonymous blocks (executed by the SQL command DO).

https://postgrespro.com/docs/postgresql/16/plpgsql-statements#PLPGSQLS TATEMENTS-GENERAL-SQL

Page 4: Returning Nothing

If the result of the query is not needed, replace SELECT with PERFORM:

=> CREATE FUNCTION do_something() RETURNS void
AS $$
BEGIN
    RAISE NOTICE 'Something has been done.';
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> DO $$
BEGIN
    PERFORM do_something();
END
$$;
NOTICE:  Something has been done.
DO

Almost any SQL command that returns no rows can be used in PL/pgSQL without modification:

=> DO $$
BEGIN
    CREATE TABLE test(n integer);
    INSERT INTO test VALUES (1),(2),(3);
    UPDATE test SET n = n + 1 WHERE n > 1;
    DELETE FROM test WHERE n = 1;
    DROP TABLE test;
END
$$;
DO

Transaction Control in Procedures

Procedures (and anonymous blocks) written in PL/pgSQL support transaction control commands:

=> CREATE TABLE test(n integer);
CREATE TABLE
=> CREATE PROCEDURE foo()
AS $$
BEGIN
    INSERT INTO test VALUES (1);
    COMMIT;
    INSERT INTO test VALUES (2);
    ROLLBACK;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CALL foo();
CALL
=> SELECT * FROM test;
 n
---
 1
(1 row)

There are certain limitations. First of all, in such cases, a procedure itself must start a new transaction, i.e., it must not be executed in the context of an already started transaction.

=> BEGIN;
BEGIN
=> CALL foo(); -- error

Page 5: Page 5

ERROR:  invalid transaction termination
CONTEXT:  PL/pgSQL function foo() line 4 at COMMIT
=> ROLLBACK;
ROLLBACK

Second, the call stack of this procedure must contain only CALL statements. In other words, if a procedure calls a procedure … that calls a procedure that performs transaction control, everything works fine:

=> CREATE OR REPLACE PROCEDURE foo()
AS $$
BEGIN
    CALL bar();
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CREATE PROCEDURE bar()
AS $$
BEGIN
    CALL baz();
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CREATE PROCEDURE baz()
AS $$
BEGIN
    COMMIT;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CALL foo(); -- it works
CALL

But should this stack include, say, a function call, the transaction would have to be completed somewhere in the middle of the statement which the context belongs to, i.e., SELECT. This is prohibited, so we get the error:

=> CREATE FUNCTION qux() RETURNS void
AS $$
BEGIN
    CALL bar();
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT qux(); -- error
ERROR:  invalid transaction termination
CONTEXT:  PL/pgSQL function baz() line 3 at COMMIT
SQL statement "CALL baz()"
PL/pgSQL function bar() line 3 at CALL
SQL statement "CALL bar()"
PL/pgSQL function qux() line 3 at CALL

Page 6: Returning a Single Row

SELECT … INTO

  • get the first returned row
  • one variable of a composite type
  • or an appropriate number of scalar variables
  • INSERT, UPDATE, DELETE RETURNING … INTO
    • get the inserted (updated, deleted) row
    • one variable of a composite type
    • or an appropriate number of scalar variables

Speaker notes

If the query result is important, you can use the INTO clause to save it into a single variable of a composite type or into several scalar variables. If the query returns several rows, only the first one makes it into the variable (you can set the output order with the ORDER BY clause). If the query returns no rows, the variables (or the attributes of the composite type variable) will be set to NULL.

In a similar manner, you can also use INSERT, UPDATE, and DELETE commands with the RETURNING clause. The difference is that these commands must not return more than one row: it will lead to an error as there is no way to specify which row is the first one.

https://postgrespro.com/docs/postgresql/16/plpgsql-statements#PLPGSQL-STATEMENTS-SQL-ONEROW

If a query returns several rows, but only one of them is used, it’s highly likely that this query is incorrect. PL/pgSQL can report such suspicious situations (and several others).

https://postgrespro.com/docs/postgresql/16/plpgsql-development-tips#PLPG SQL-EXTRA-CHECKS

More powerful debugging capabilities are provided by an external extension plpgsql_check (developed by Pavel Stehule).

https://github.com/okbob/plpgsql\_check

Page 7: Returning a Single Row

A SELECT command returning a single row is probably the most frequently used one in PL/pgSQL. Here is an example that displays one row, despite the query within the anonymous block returning two:

=> CREATE TABLE t(id integer, code text);
CREATE TABLE
=> INSERT INTO t VALUES (1, 'One'), (2, 'Two');
INSERT 0 2
=> DO $$
DECLARE
    r record;
BEGIN
    SELECT id, code INTO r FROM t;
    RAISE NOTICE '%', r;
END
$$;
NOTICE:  (1,One)
DO
Note

Note Note the construct in the PostgreSQL code that is very similar to SELECT INTO command from SQL. In the command, after INTO, there is the name of the new table, created and filled with the query output. In PL/pgSQL, the equivalent syntax would be CREATE TABLE … AS SELECT. INSERT, UPDATE, and DELETE commands can also return the result using the RETURNING clause. They can be used in PL/pgSQL with the INTO clause, just like SELECT:

=> DO $$
DECLARE
    r record;
BEGIN
    UPDATE t SET code = code || '!' WHERE id = 1 RETURNING * INTO r;
    RAISE NOTICE 'Modified: %', r;
END
$$;
NOTICE:  Modified: (1,One!)
DO

Compile-Time and Run-Time Checks for Routines

In some suspicious cases, PL/pgSQL can issue warnings. Warnings can be enabled with the following parameter (set to none by default):

=> SET plpgsql.extra_warnings = 'all';
SET
=> CREATE PROCEDURE bugs(INOUT a integer)
AS $$
DECLARE
    a integer;
    b integer;
BEGIN
    SELECT id INTO a, b FROM t;
END
$$ LANGUAGE plpgsql;
WARNING:  variable "a" shadows a previously defined variable
LINE 4:     a integer;
            ^
CREATE PROCEDURE

This is a warning about two variable declarations that override each other:

=> CALL bugs(42);

Page 8: Page 8

WARNING:  query returned more than one row
HINT:  Make sure the query returns a single row, or use LIMIT 1.
WARNING:  number of source and target fields in assignment does not match
DETAIL:  strict_multi_assignment check of extra_warnings is active.
HINT:  Make sure the query returns the exact list of columns.
 a
----
 42
(1 row)

And here we can see two run-time warnings: the query has returned more than one row, and the INTO clause provides the wrong number of parameters (PL/pgSQL will assign NULL to the second one). There are no other checks available at the moment, but they can appear in the next PostgreSQL versions. The plpgsql.extra_warnings value can specify some particular checks. A similar parameter plpgsql.extra_errors enables throwing errors instead of warnings.

=> RESET plpgsql.extra_warnings;
RESET

You can perform more extensive code checks using plpgsql_check, an external extension developed and supported by Pavel Stehule. This extension is already installed in the VM provided for this course.

=> CREATE SCHEMA plpgsql_check;
CREATE SCHEMA
=> CREATE EXTENSION plpgsql_check SCHEMA plpgsql_check;
CREATE EXTENSION
=> SELECT * FROM plpgsql_check.plpgsql_check_function('bugs(integer)');
                        plpgsql_check_function
-----------------------------------------------------------------------
 warning:00000:5:statement block:parameter "a" is shadowed
 Detail: Local variable shadows function parameter.
 warning:00000:6:SQL statement:too few attributes for target variables
 Query: SELECT id           FROM t
 --     ^
 Detail: There are more target variables than output columns in query.
 Hint: Check target variables in SELECT INTO statement.
 Context: at SQL statement to a, b variables
 warning extra:00000:3:DECLARE:never read variable "a"
 warning extra:00000:4:DECLARE:never read variable "b"
 warning extra:00000:unused parameter "a"
 warning extra:00000:unmodified OUT variable "a"
(12 rows)

Unused variables and an output parameter without an assigned value have been detected. The plpgsql_checks extension provides many debugging features, including run-time error detection. It also includes a profiler for tuning PL/pgSQL code.

Eliminating Naming Ambiguities

Will the following code be executed successfully?

=> DO $$
DECLARE
    id   integer := 1;
    code text;
BEGIN
    SELECT id, code INTO id, code
    FROM t WHERE id = id;
    RAISE NOTICE '%, %', id, code;
END
$$;
ERROR:  column reference "id" is ambiguous
LINE 1: SELECT id, code                   FROM t WHERE id = id
               ^
DETAIL:  It could refer to either a PL/pgSQL variable or a table column.
QUERY:  SELECT id, code                   FROM t WHERE id = id
CONTEXT:  PL/pgSQL function inline_code_block line 6 at SQL statement

No, because of the ambiguity in SELECT: id can mean both the name of the column and the name of the variable. Note that there is

Page 9: Page 9

no ambiguity in the INTO clause: it relates to PL/pgSQL only. By the way, the message shows that PL/pgSQL cuts off the INTO clause before passing the query to SQL. There are several approaches to eliminating such ambiguities. The first one is to prevent them. It can be achieved by prepending variable names with a prefix, which usually corresponds to the variable type, for example: p_ for parameters l_ or v_ for regular (local) variables c_ for constants It is a simple and effective method if you use it consistently and never add prefixes to column names. Its disadvantage is that it makes the code look sloppy and somewhat clumsy because of extra underscores. This is how it can look like in our example:

=> DO $$
DECLARE
    l_id   integer := 1;
    l_code text;
BEGIN
    SELECT id, code INTO l_id, l_code
    FROM t WHERE id = l_id;
    RAISE NOTICE '%, %', l_id, l_code;
END
$$;
NOTICE:  1, One!
DO

Another approach is to use qualified names, i.e., prepend the object name with a qualifier followed by a dot: A name or an alias of a table for columns A block label for variables A function name for parameters This approach is more straightforward than adding prefixes because it works for any column names. This is how our example will look like if we use qualifiers:

=> DO $$
<<local>>
DECLARE
    id   integer := 1;
    code text;
BEGIN
    SELECT t.id, t.code INTO local.id, local.code
    FROM t WHERE t.id = local.id;
    RAISE NOTICE '%, %', id, code;
END
$$;
NOTICE:  1, One!
DO

The third approach is to prioritize variables over columns, or vice versa. Such prioritization is managed by the plpgsql.variable_conflict configuration parameter. Its possible values are use_column, use_variable, error. In some cases, it facilitates conflict resolution, but it does not eliminate conflicts altogether. Besides, such an implicit rule (which can suddenly change, to make things worse) is sure to cause some code to be executed in a way unforeseen by the developer. Nevertheless, let’s see an example. Here variables have priority, so it is enough to add qualifiers to table columns only:

=> SET plpgsql.variable_conflict = use_variable;
SET
=> DO $$
DECLARE
    id   integer := 1;
    code text;
BEGIN
    SELECT t.id, t.code INTO id, code
    FROM t WHERE t.id = id;
    RAISE NOTICE '%, %', id, code;
END
$$;

Page 10: Page 10

NOTICE:  1, One!
DO
=> RESET plpgsql.variable_conflict;  -- reset the value to default
RESET
=> SHOW plpgsql.variable_conflict;
 plpgsql.variable_conflict
---------------------------
 error
(1 row)

Which approach to follow is a matter of taste and experience. We recommend choosing either the first or the second one (prefixes or qualifiers); make sure not to use both in one project because consistency can greatly assist code comprehension. In this course, we are going to use qualifiers, but only where it’s absolutely necessary, so that examples don’t get too cluttered. However, in production code you should always take care of all possible ambiguities: there is no guarantee that a new column won’t have the same name as your variable.

Page 11: Checking Results

INTO STRICT

  • guarantees that exactly one row is returned, no more and no less
  • ROW_COUNT diagnostics
    • the number of rows returned (processed) by the last SQL command
  • FOUND variable
    • after an SQL command: true if the command has returned (processed) a row
    • after a loop: indicates that at least one iteration has been completed

Speaker notes

By adding the STRICT keyword to the INTO clause, we can guarantee that the command returns or processes exactly one row; otherwise, an error occurs.

Besides, we can check the status of the SQL command that has just been executed (unless it has completed with an error). There are two ways to go about it.

The first option is to use the GET DIAGNOSTICS clause to get the number of rows processed by the command.

The second option is to use the boolean variable FOUND, which shows whether the command has processed any data at all.

You can also use FOUND as an indicator that the loop body has been executed at least once.

https://postgrespro.com/docs/postgresql/16/plpgsql-statements#PLPGSQL-STATEMENTS-DIAGNOSTICS

Page 12: Exactly One Row

What will happen if the query returns several rows?

=> DO $$
DECLARE
    r record;
BEGIN
    SELECT id, code INTO r FROM t;
    RAISE NOTICE '%', r;
END
$$;
NOTICE:  (2,Two)
DO

Only the first row will be written into the variable. Since we have not specified the ORDER BY clause, the order is virtually unpredictable:

=> SELECT * FROM t;
 id | code
----+------
  2 | Two
  1 | One!
(2 rows)

INSERT, UPDATE, and DELETE commands do not allow specifying the order of the rows, so a command that affects several rows results in an error:

=> DO $$
DECLARE
    r record;
BEGIN
    UPDATE t SET code = code || '!' RETURNING * INTO r;
    RAISE NOTICE 'Modified: %', r;
END
$$;
ERROR:  query returned more than one row
HINT:  Make sure the query returns a single row, or use LIMIT 1.
CONTEXT:  PL/pgSQL function inline_code_block line 5 at SQL statement

And what if the query returns no rows at all?

=> DO $$
DECLARE
    r record;
BEGIN
    r := (-1,'!!!');
    SELECT id, code INTO r FROM t WHERE false;
    RAISE NOTICE '%', r;
END
$$;
NOTICE:  (,)
DO

Variables will contain undefined values. It is also true for INSERT, UPDATE, and DELETE commands. For example:

=> DO $$
DECLARE
    r record;
BEGIN
    UPDATE t SET code = code || '!' WHERE id = -1
        RETURNING * INTO r;
    RAISE NOTICE 'Modified: %', r;
END
$$;
NOTICE:  Modified: (,)
DO

Sometimes you may want to be sure that the query retrieves exactly one row, no more and no less. In this case, it is convenient to use the INTO STRICT clause:

Page 13: Page 13

=> DO $$
DECLARE
    r record;
BEGIN
    SELECT id, code INTO STRICT r FROM t;
    RAISE NOTICE '%', r;
END
$$;
ERROR:  query returned more than one row
HINT:  Make sure the query returns a single row, or use LIMIT 1.
CONTEXT:  PL/pgSQL function inline_code_block line 5 at SQL statement
=> DO $$
DECLARE
    r record;
BEGIN
    SELECT id, code INTO STRICT r FROM t WHERE false;
    RAISE NOTICE '%', r;
END
$$;
ERROR:  query returned no rows
CONTEXT:  PL/pgSQL function inline_code_block line 5 at SQL statement

As we have seen, INSERT, UPDATE, and DELETE commands that affect several rows result in an error. The STRICT keyword guarantees that there will be exactly one row (and not zero):

=> DO $$
DECLARE
    r record;
BEGIN
    UPDATE t SET code = code || '!' WHERE id = -1 RETURNING * INTO STRICT r;
    RAISE NOTICE 'Modified: %', r;
END
$$;
ERROR:  query returned no rows
CONTEXT:  PL/pgSQL function inline_code_block line 5 at SQL statement

Explicit Checks of the Execution State

You can also check the state of the last SQL command executed: The GET DIAGNOSTICS command retrieves the number of processed rows (ROW_COUNT). A predefined boolean variable FOUND shows whether any row has been processed.

=> DO $$
DECLARE
    r record;
    rowcount integer;
BEGIN
    SELECT id, code INTO r FROM t WHERE false;
    GET DIAGNOSTICS rowcount := ROW_COUNT;
    RAISE NOTICE 'rowcount = %', rowcount;
    RAISE NOTICE 'found = %', FOUND;
END
$$;
NOTICE:  rowcount = 0
NOTICE:  found = f
DO
=> DO $$
DECLARE
    r record;
    rowcount integer;
BEGIN
    SELECT id, code INTO r FROM t;
    GET DIAGNOSTICS rowcount := ROW_COUNT;
    RAISE NOTICE 'rowcount = %', rowcount;
    RAISE NOTICE 'found = %', FOUND;
END
$$;
NOTICE:  rowcount = 1
NOTICE:  found = t
DO

Page 14: Page 14

Note

Note: such diagnostics does not detect that the query affects several rows. ROW_COUNT returns 1, since only one row from the output has been saved to the variable r.

Page 15: Set Returning Functions

Query rows

RETURN QUERY query;
  • One row
    • RETURN NEXT expression;
    • if there are no output parameters
    • RETURN NEXT;
    • if there are output parameters
  • Features
    • rows are added to the result, but function execution is not terminated
    • commands can be executed several times
    • the result is not returned until the function completes

Speaker notes

To create a table function in PL/pgSQL, you have to declare it with a clause RETURNS SETOF or RETURNS TABLE (just like in SQL).

Commands RETURN QUERY and RETURN NEXT assemble individual elements of the returning value in a special buffer. These commands do not interrupt execution. They can be called multiple times within a function body, each call adding elements to the final output. When a function finishes (by a RETURN call with no parameters or simply by reaching the end of the function body), the output that has been assembled in the buffer is returned.

If a function finishes after executing the RETURN QUERY query command once, the output will be identical to that of an SQL function running the query as its last. However, the query from an SQL function could be substituted into the main query, while with PL/pgSQL this is not the case.

The result can also be returned row by row using the RETURN NEXT construct. It is similar to an ordinary RETURN, but instead of stopping the function execution, it adds the return value as another row of the future result.

Thus, the RETURN NEXT and RETURN QUERY commands do not work as yield in the generator functions of modern languages.

https://postgrespro.com/docs/postgresql/16/plpgsql-control-structures#PLP GSQL-STATEMENTS-RETURNING

Page 16: Set Returning Functions

Here is an example of a set-returning function written in PL/pgSQL:

=> CREATE FUNCTION t() RETURNS TABLE(LIKE t)
AS $$
BEGIN
    RETURN QUERY SELECT id, code FROM t ORDER BY id;
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT * FROM t();
 id | code
----+------
  1 | One!
  2 | Two
(2 rows)

Another option is to return values row by row.

=> CREATE FUNCTION days_of_week() RETURNS SETOF text
AS $$
BEGIN
    FOR i IN 7 .. 13 LOOP
        RETURN NEXT to_char(to_date(i::text,'J'),'TMDy');
    END LOOP;
END;
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT * FROM days_of_week() WITH ORDINALITY;
 days_of_week | ordinality
--------------+------------
 Mon          |          1
 Tue          |          2
 Wed          |          3
 Thu          |          4
 Fri          |          5
 Sat          |          6
 Sun          |          7
(7 rows)

Why is this function declared STABLE, not IMMUTABLE? At first sight, it seems that repeat function calls will return the same value, it is still affected by the current locale:

=> SET lc_time = 'ru_RU.UTF8';
SET
=> SELECT * FROM days_of_week() WITH ORDINALITY;
 days_of_week | ordinality
--------------+------------
 Пн           |          1
 Вт           |          2
 Ср           |          3
 Чт           |          4
 Пт           |          5
 Сб           |          6
 Вс           |          7
(7 rows)

The following example shows a mixed approach: create a function that returns a list of routines that belong to its schema. It uses PG_ROUTINE_OID diagnostics item to get its oid:

Page 17: Page 17

=> CREATE FUNCTION where_am_i() RETURNS TABLE(name text, isitme text)
AS $$
DECLARE
    my_oid oid;
    schema_oid oid;
BEGIN
    GET DIAGNOSTICS my_oid := PG_ROUTINE_OID;
    -- schema oid
    schema_oid := pronamespace FROM pg_proc WHERE oid = my_oid;
    -- Header
    name := '=== Schema: ' || schema_oid::regnamespace;
    RETURN NEXT;  -- header with schema name
    -- list of routines
    RETURN QUERY
SELECT proname::text, CASE WHEN oid = my_oid THEN 'It''s me!' END
FROM pg_proc
WHERE pronamespace = schema_oid
ORDER BY 1;
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

Run the function:

=> SELECT * FROM where_am_i();
        name        |  isitme
--------------------+----------
 === Schema: public |
 bar                |
 baz                |
 bugs               |
 days_of_week       |
 do_something       |
 foo                |
 qux                |
 t                  |
 where_am_i         | It's me!
(10 rows)

Page 18: Takeaways

PL/pgSQL is closely integrated with SQL

  • procedural code can contain queries
  • (provided as expressions or as separate commands)
  • queries can include variables
  • you can get query results and check query status
  • Naming ambiguities are an issue

Page 19: Practice

  1. Create the add_author function for adding new authors. The function must take three parameters (last name, first name, middle name) and return the ID of the added author.
  • Check that the application allows adding new authors. 2. Create the buy_book function for buying books. The function takes the book ID as a parameter and reduces the number of such books by one. There is no return value.
    • Check that the Store tab now allows buying books.

Speaker notes

FUNCTION add_author(last_name text, first_name text, middle_name
text)
RETURNS integer
FUNCTION buy_book(book_id integer)
RETURNS void

You can notice that the application allows selling more books than there are in store. If the total number of books were stored in a table column, adding a CHECK constraint would be a good and simple solution. But our implementation calculates the number of books, so we are not going to address this until we get to the Triggers lesson of the course.

Page 20: 1. The add_author Function

=> CREATE FUNCTION add_author(
    last_name text,
    first_name text,
    middle_name text
) RETURNS integer
AS $$
DECLARE
    author_id integer;
BEGIN
    INSERT INTO authors(last_name, first_name, middle_name)
        VALUES (last_name, first_name, middle_name)
        RETURNING authors.author_id INTO author_id;
    RETURN author_id;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

2. The buy\_book Function

=> CREATE FUNCTION buy_book(book_id integer)
RETURNS void
AS $$
BEGIN
    INSERT INTO operations(book_id, qty_change)
        VALUES (book_id, -1);
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Page 21: Practice+

Create a game in which the server tries to guess the animal chosen by the user by asking a series of clarifying questions that can be answered with a “yes” or a “no”. If the server suggests a wrong answer, it requests the user to provide the animal name, and then asks how is it different from its suggestion. This new information is registered for use in the next games. 1. Create a table for data representation. 2. Design an interface and implement all the necessary functions. 3. Check the implementation.

Speaker notes

A possible dialog (between humans):

  • Is it a mammal?
  • Yes.
  • Is it an elephant?
  • No.
  • I give up. What is it?
  • It’s a whale.
  • How is it different from an elephant?
  • It lives in water.
  1. The data falls nicely into a binary tree structure. Inner nodes store questions, while leaf nodes store animal names. One of the child nodes corresponds to the answer “yes”, the other to the answer “no”.

  2. Between the function calls, you need to pass the information about the last node of the tree the user gets to (the context of the dialog). For example, you could have the following functions:

● start the game (no input context): FUNCTION start_game(OUT context integer, OUT question text)

● continue the game (get the answer, ask the next question): FUNCTION continue_game( INOUT context integer, IN answer boolean, OUT you_win boolean, OUT question text)

● end the game (add information about another animal): FUNCTION end_game( IN context integer, IN name text, IN question text) RETURNS void

Page 22: 1. Table

=> CREATE DATABASE plpgsql_queries;
CREATE DATABASE
=> \c plpgsql_queries
You are now connected to database "plpgsql_queries" as user "student".
=> CREATE TABLE animals(
    id     integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    yes_id integer REFERENCES animals(id),
    no_id  integer REFERENCES animals(id),
    name   text
);
CREATE TABLE
=> INSERT INTO animals(name) VALUES
    ('mammals'), ('elephant'), ('turtle');
INSERT 0 3
=> UPDATE animals SET yes_id = 2, no_id = 3 WHERE id = 1;
UPDATE 1
=> SELECT * FROM animals ORDER BY id;
 id | yes_id | no_id |   name
----+--------+-------+----------
  1 |      2 |     3 | mammals
  2 |        |       | elephant
  3 |        |       | turtle
(3 rows)

The first row represents the root of the tree.

2. Functions

=> CREATE FUNCTION start_game(
    OUT context integer,
    OUT question text
)
AS $$
DECLARE
    root_id CONSTANT integer := 1;
BEGIN
    SELECT id, name||'?'
    INTO context, question
    FROM animals
    WHERE id = root_id;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

Page 23: Page 23

=> CREATE FUNCTION continue_game(
    INOUT context integer,
    IN answer boolean,
    OUT you_win boolean,
    OUT question text
)
AS $$
DECLARE
    new_context integer;
BEGIN
    SELECT CASE WHEN answer THEN yes_id ELSE no_id END
    INTO new_context
    FROM animals
    WHERE id = context;
    IF new_context IS NULL THEN
        you_win := NOT answer;
        question := CASE
            WHEN you_win THEN 'I concede'
            ELSE 'You lose'
        END;
    ELSE
        SELECT id, null, name||'?'
        INTO context, you_win, question
        FROM animals
        WHERE id = new_context;
    END IF;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE FUNCTION end_game(
    IN context integer,
    IN name text,
    IN question text
) RETURNS void
AS $$
DECLARE
    new_animal_id integer;
    new_question_id integer;
BEGIN
    INSERT INTO animals(name) VALUES (name)
        RETURNING id INTO new_animal_id;
    INSERT INTO animals(name) VALUES (question)
        RETURNING id INTO new_question_id;
    UPDATE animals SET yes_id = new_question_id
    WHERE yes_id = context;
    UPDATE animals SET  no_id = new_question_id
    WHERE  no_id = context;
    UPDATE animals SET yes_id = new_animal_id, no_id = context
    WHERE id = new_question_id;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

3. Example of a Game Session

Try the word “whale”.

=> SELECT * FROM start_game();
 context | question
---------+----------
       1 | mammals?
(1 row)
=> SELECT * FROM continue_game(1,true);
 context | you_win | question
---------+---------+-----------
       2 |         | elephant?
(1 row)
=> SELECT * FROM continue_game(2,false);

Page 24: Page 24

 context | you_win | question
---------+---------+-----------
       2 | t       | I concede
(1 row)
=> SELECT * FROM end_game(2,'whale','lives in water');
 end_game
----------
(1 row)

This is the table now:

=> SELECT * FROM animals ORDER BY id;
 id | yes_id | no_id |      name
----+--------+-------+----------------
  1 |      5 |     3 | mammals
  2 |        |       | elephant
  3 |        |       | turtle
  4 |        |       | whale
  5 |      4 |     2 | lives in water
(5 rows)

Try “whale” again.

=> SELECT * FROM start_game();
 context | question
---------+----------
       1 | mammals?
(1 row)
=> SELECT * FROM continue_game(1,true);
 context | you_win |    question
---------+---------+-----------------
       5 |         | lives in water?
(1 row)
=> SELECT * FROM continue_game(5,true);
 context | you_win | question
---------+---------+----------
       4 |         | whale?
(1 row)
=> SELECT * FROM continue_game(4,true);
 context | you_win | question
---------+---------+----------
       4 | f       | You lose
(1 row)
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_queries;
DROP DATABASE

13 Lesson 13: PL/pgSQL / Cursors

Native Markdown source page: PL/pgSQL / Cursors
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_13_plpgsql_cursors.html Archive: DEV1-handouts-16-en.zip PDF pages: 19

Page 1: PL/pgSQL / Cursors

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • What are Cursors
  • Declaration and Opening
  • Operations with Cursors
  • Loops over Cursors and Query Results
  • Passing a Cursor to Clients

Page 3: What are Cursors

A cursor implies iterative processing

  • a full output takes too much memory
  • only some part of the output is needed, but its size is unknown beforehand
  • allows the client to control the output size
  • you really need row-by-row processing (you usually don’t)

Speaker notes

We have already learned about the concept of cursors in the Architecture. PostgreSQL overview topic. In that topic, cursors were presented as a server feature, and we explained how to access them using SQL. Now let’s talk about how to use cursors in PL/pgSQL.

Why do we need cursors at all? SQL is a declarative language; first and foremost, it is designed to work with sets of rows, which is its strength and advantage. Being a procedural language, PL/pgSQL has to work with data row by row, using explicit loops. This can be achieved via cursors.

For example, a full SELECT result may take up too much memory, so it has to be processed piece by piece, or the required size of the output is unknown beforehand, for example, the query must be stopped at some point, or you need to let the client manage the output.

(Although row-by-row processing may be required from time to time, the same outcome can often be achieved using pure SQL, with simpler and more efficient code.)

Page 4: Declaration and Opening

Unbound cursor variables

  • a variable of the refcursor type is declared
  • the actual query is specified when the cursor is being opened
  • Bound cursor variables
    • Not supported in SQL
    • the query is specified at declaration time (possibly with parameters)
    • the arguments are passed when the cursor is opened
  • Features
    • the value of the cursor variable is the cursor name
    • (can be specified explicitly or generated automatically)
    • PL/pgSQL variables become implicit parameters of the query
    • (their arguments are filled in when the cursor is opened)
    • the query is prepared

Speaker notes

SQL uses a single DECLARE command that both declares and opens a cursor. In PL/pgSQL, these are two different steps. Besides, there are so-called cursor variables that are used for cursor access. These variables have the refcursor type and virtually contain the name of the cursor (if you do not provide this name explicitly, PL/pgSQL ensures that it is unique).

A cursor variable can be declared without being bound to a particular query. Then you have to specify the query when you open the cursor.

Alternatively, you can specify the query (including parameters) when declaring a variable. Then, you’ll only have to pass the arguments when opening the cursor.

These methods are equivalent; which one to use is a matter of taste. Both bound and unbound cursor variables are initialized only when the cursor is being opened. In both cases, a query can have implicit parameters, which are derived from PL/pgSQL variables.

A query opened with a cursor is prepared automatically.

https://postgrespro.com/docs/postgresql/16/plpgsql-cursors#PLPGSQL-CU RSOR-DECLARATIONS

https://postgrespro.com/docs/postgresql/16/plpgsql-cursors#PLPGSQL-CU RSOR-OPENING

Page 5: Declaration and Opening

Create a table:

=> CREATE TABLE t(id integer, s text);
CREATE TABLE
=> INSERT INTO t VALUES (1, 'One'), (2, 'Two'), (3, 'Three');
INSERT 0 3

Unbound variable:

=> DO $$
DECLARE
    -- declare a variable
    cur refcursor;
BEGIN
    -- bind with a query and open
    OPEN cur FOR SELECT * FROM t;
END
$$;
DO

Bound variable: the query is added during declaration. The variable cur is still of the refcursor type.

=> DO $$
DECLARE
    -- declare a variable and bind
    cur CURSOR FOR SELECT * FROM t;
BEGIN
    -- open the cursor
    OPEN cur;
END
$$;
DO

For bound variables, the query may be parameterized.

Note

Note Note the name disambiguation in this example and the next one:

=> DO $$
DECLARE
    -- declare a variable and bind
    cur CURSOR(id integer) FOR SELECT * FROM t WHERE t.id = cur.id;
BEGIN
    -- pass arguments and open the cursor
    OPEN cur(1);
END
$$;
DO

PL/pgSQL variables also serve as (implicit) cursor parameters.

=> DO $$
<<local>>
DECLARE
    id integer := 3;
    -- declare a variable and bind
    cur CURSOR FOR SELECT * FROM t WHERE t.id = local.id;
BEGIN
    id := 1;
    -- open the cursor (the value of id is passed at this moment)
    OPEN cur;
END
$$;
DO

You can use not only the SELECT command, but also any other command that returns a result (for example, INSERT, UPDATE, DELETE with the RETURNING keyword) as a query.

Page 6: Operations with Cursors

  • in SQL
  • the batch size
  • is configurable
  • Fetch
    • row by row only
  • Access the current row
    • for simple queries only (one table, no grouping or sorting)
  • Processing is usually performed in a loop
    • a FOR loop over a cursor
    • a FOR loop over a query without an explicitly declared cursor
    • in SQL
    • DECLARE
    • WITH HOLD
  • Close
    • explicitly or automatically at transaction end

Speaker notes

PL/pgSQL allows fetching data from a cursor row by row only. It is done via the FETCH INTO command.

If the query is simple enough (it works with one table, without grouping or sorting), it is possible to access the current row of the cursor within commands such as UPDATE or DELETE.

Procedural processing implies looping through data. The rows returned by a cursor can be iterated through and processed using control commands that are already familiar to us. But since such loops are required quite often, PL/pgSQL offers a special flavor of the FOR command that implements them. We have already seen FOR working with integers in the PL/pgSQL. Overview and Programming Structures section; this one works with cursors. Moreover, there is one more flavor of the FOR loop that does not require cursor declaration at all: the query itself is specified within the command.

A cursor can be explicitly closed by the CLOSE command, but it will be closed anyway once the transaction is complete (in SQL, a cursor can remain open even after the transaction has finished if you have specified WITH HOLD).

https://postgrespro.com/docs/postgresql/16/plpgsql-cursors#PLPGSQL-CU RSOR-USING

https://postgrespro.com/docs/postgresql/16/plpgsql-cursors#PLPGSQL-CU RSOR-FOR-LOOP

Page 7: Operations with Cursors

The FETCH command reads the next row from the cursor position. To move to the next row without reading anything, use the MOVE command. What will this example return?

=> DO $$
DECLARE
    cur refcursor;
    rec record; -- you can use several scalar variables instead
BEGIN
    OPEN cur FOR SELECT * FROM t ORDER BY id;
    MOVE cur;
    FETCH cur INTO rec;
    RAISE NOTICE '%', rec;
    CLOSE cur;
END
$$;
NOTICE:  (2,Two)
DO

Usually, such data retrieval is done within a loop. For example:

=> DO $$
DECLARE
    cur refcursor;
    rec record;
BEGIN
    OPEN cur FOR SELECT * FROM t;
    LOOP
        FETCH cur INTO rec;
        EXIT WHEN NOT FOUND; -- FOUND: was the row retrieved?
        RAISE NOTICE '%', rec;
    END LOOP;
    CLOSE cur;
END
$$;
NOTICE:  (1,One)
NOTICE:  (2,Two)
NOTICE:  (3,Three)
DO

PL/pgSQL offers a cursor FOR loop that does the same thing, but with much less code:

=> DO $$
DECLARE
    cur CURSOR FOR SELECT * FROM t;
    -- loop variable is not declared
BEGIN
    FOR rec IN cur LOOP -- cur should be bound to a query
        RAISE NOTICE '%', rec;
    END LOOP;
END
$$;
NOTICE:  (1,One)
NOTICE:  (2,Two)
NOTICE:  (3,Three)
DO

Moreover, you can forego explicit cursor commands altogether if the loop is all you really need. Parentheses around the query are optional, but convenient.

=> DO $$
DECLARE
    rec record; -- should be declared explicitly
BEGIN
    FOR rec IN (SELECT * FROM t) LOOP
        RAISE NOTICE '%', rec;
    END LOOP;
END
$$;

Page 8: Page 8

NOTICE:  (1,One)
NOTICE:  (2,Two)
NOTICE:  (3,Three)
DO

As with any loop, you can place a label here, which can be useful in nested loops. What will be output here?

=> DO $$
DECLARE
    rec_outer record;
    rec_inner record;
BEGIN
    <<outer>>
    FOR rec_outer IN (SELECT * FROM t ORDER BY id) LOOP
        <<inner>>
        FOR rec_inner IN (SELECT * FROM t ORDER BY id) LOOP
            EXIT outer WHEN rec_inner.id = 3;
            RAISE NOTICE '%, %', rec_outer, rec_inner;
        END LOOP inner;
    END LOOP outer;
END
$$;
NOTICE:  (1,One), (1,One)
NOTICE:  (1,One), (2,Two)
DO

After the loop is done, the FOUND variable will tell if at least one row got processed:

=> DO $$
DECLARE
    rec record;
BEGIN
    FOR rec IN (SELECT * FROM t WHERE false) LOOP
        RAISE NOTICE '%', rec;
    END LOOP;
    RAISE NOTICE 'Was there at least one iteration? %', FOUND;
END
$$;
NOTICE:  Was there at least one iteration? f
DO

The current row of the cursor bound to a simple query (single table, no grouping or sorting) can be referenced with the CURRENT OF clause. One typical usecase for this is batch processing tasks and updating their status live.

=> DO $$
DECLARE
    cur refcursor;
    rec record;
BEGIN
    OPEN cur FOR SELECT * FROM t
        FOR UPDATE; -- the rows are locked as they are processed
    LOOP
        FETCH cur INTO rec;
        EXIT WHEN NOT FOUND;
        UPDATE t SET s = s || ' (processed)' WHERE CURRENT OF cur;
    END LOOP;
    CLOSE cur;
END
$$;
DO
=> SELECT * FROM t;
 id |         s
----+-------------------
  1 | One (processed)
  2 | Two (processed)
  3 | Three (processed)
(3 rows)
Note

Note Note that CURRENT OF does not work with FOR loops over queries, because they do not use the cursor explicitly. You can explicitly specify a unique table key (’WHERE id = rec.id’), of course, but CURRENT OF is faster and does not need an index. With all that said, a single SQL statement can do the job better and faster than a set of loops. Loops are often overused because of their familiar, procedural programming vibe. But databases are built different.

Page 9: Page 9

For example:

=> BEGIN;
DO $$
DECLARE
    rec record;
BEGIN
    FOR rec IN (SELECT * FROM t) LOOP
        RAISE NOTICE '%', rec;
        DELETE FROM t WHERE id = rec.id;
    END LOOP;
END
$$;
ROLLBACK;
BEGIN
NOTICE:  (1,"One (processed)")
NOTICE:  (2,"Two (processed)")
NOTICE:  (3,"Three (processed)")
DO
ROLLBACK

This loop is equivalent to one simple SQL command:

=> BEGIN;
DELETE FROM t RETURNING *;
ROLLBACK;
BEGIN
 id |         s
----+-------------------
  1 | One (processed)
  2 | Two (processed)
  3 | Three (processed)
(3 rows)
DELETE 3
ROLLBACK

Page 10: Passing a Cursor to Clients

Source slide page 10: Passing a Cursor to Clients{#source-figure-010 caption=“Source slide page 10: Passing a Cursor to Clients”}

Speaker notes

A PL/pgSQL cursor variable (of the refcursor type) contains the name of an open SQL cursor. To denote the memory allocated in the backend for keeping the cursor state, the term portal is used.

This way, a PL/pgSQL function can open a cursor and return its name to the client. Then, the client will be able to work with the cursor as if it has been opened by this client, but will have access only to the provided data. It adds one more way of setting up the interface between the database and the application.

Page 11: Passing a Cursor to Clients

Open a cursor and print the value of the cursor variable:

=> DO $$
DECLARE
    cur refcursor;
BEGIN
    OPEN cur FOR SELECT * FROM t;
    RAISE NOTICE '%', cur;
END
$$;
NOTICE:  <unnamed portal 14>
DO

This is the name of the cursor (portal) that was opened on the server. It was generated automatically. You can name it explicitly if you want (but it must be unique for the session):

=> DO $$
DECLARE
    cur refcursor := 'cursor12345';
BEGIN
    OPEN cur FOR SELECT * FROM t;
    RAISE NOTICE '%', cur;
END
$$;
NOTICE:  cursor12345
DO

With this, you can create a function that takes a cursor and returns its name:

=> CREATE FUNCTION t_cur() RETURNS refcursor
AS $$
DECLARE
    cur refcursor;
BEGIN
    OPEN cur FOR SELECT * FROM t;
    RETURN cur;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

The client starts a transaction…

=> BEGIN;
BEGIN

…calls the function, gets the cursor name…

=> SELECT t_cur();
        t_cur
---------------------
 <unnamed portal 15>
(1 row)

…and starts reading from it (the quotation marks are necessary because of the special characters in the name):

=> FETCH "<unnamed portal 15>";
 id |        s
----+-----------------
  1 | One (processed)
(1 row)
=> COMMIT;
COMMIT

You can let the client set the cursor name:

=> DROP FUNCTION t_cur();

Page 12: Page 12

DROP FUNCTION
=> CREATE FUNCTION t_cur(cur refcursor) RETURNS void
AS $$
BEGIN
    OPEN cur FOR SELECT * FROM t;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

The client code is simplified:

=> BEGIN;
BEGIN
=> SELECT t_cur('cursor12345');
 t_cur
-------
(1 row)
=> FETCH cursor12345;
 id |        s
----+-----------------
  1 | One (processed)
(1 row)
=> COMMIT;
COMMIT

The function can also return several open cursors using OUT parameters. This makes it possible for the client to read data from multiple tables within one function call. An alternative is to bundle the data from different sources into a JSON or XML on the server and then return it. This is covered in the DEV2 course.

Page 13: Takeaways

A cursor allows fetching and processing data row by row

  • A FOR loop can simplify cursor handling
  • Processing data in loops is common for procedural languages, but should not be overused

Page 14: Practice

  1. Modify the book_name function: if the book has more than two authors, the title should include only the first two, while the rest are to be replaced with “et al.”
  • Check that the function works fine in SQL and in the application. 2. Try rewriting the book_name function in SQL. Which implementation do you prefer: PL/pgSQL or SQL?
  1. For example:
101 Famous Poems. Alexander S. Pushkin, William Shakespeare, Ivan
A. Bunin →
→ 101 Famous Poems. Alexander S. Pushkin, William Shakespeare, et
al.

Page 15: 1. book_name Function (Author Abbreviations)

A more universal function can be created that takes an additional parameter: the maximum number of authors in a book name. Since the function changes its signature (the number and/or types of input parameters), it must first be deleted and then recreated. In this case, the function has a dependent object, the catalog_v view, in which it is used. The view will also have to be recreated (in reality, all these actions must be performed in a single transaction for the changes to take effect atomically).

=> DROP FUNCTION book_name(integer,text) CASCADE;
NOTICE:  drop cascades to 2 other objects
DETAIL:  drop cascades to view catalog_v
drop cascades to function get_catalog(text,text,boolean)
DROP FUNCTION
=> CREATE FUNCTION book_name(
    book_id integer,
    title text,
    maxauthors integer DEFAULT 2
)
RETURNS text
AS $$
DECLARE
    r record;
    res text := shorten(title);
BEGIN
    IF (right(res, 3) != '...') THEN res := res || '.'; END IF;
    res := res || ' ';
    FOR r IN (
        SELECT a.last_name, a.first_name, a.middle_name, ash.seq_num
        FROM   authors a
               JOIN authorship ash ON a.author_id = ash.author_id
        WHERE  ash.book_id = book_name.book_id
        ORDER BY ash.seq_num
    )
    LOOP
        EXIT WHEN r.seq_num > maxauthors;
        res := res || author_name(r.last_name, r.first_name, r.middle_name) || ', ';
    END LOOP;
    res := rtrim(res, ', ');
    IF r.seq_num > maxauthors THEN
        res := res || ' et al.';
    END IF;
    RETURN res;
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE OR REPLACE VIEW catalog_v AS
SELECT b.book_id,
       b.title,
       b.onhand_qty,
       book_name(b.book_id, b.title) AS display_name,
       b.authors
FROM   books b
ORDER BY display_name;
CREATE VIEW
=> SELECT book_id, display_name FROM catalog_v;
 book_id |                          display_name
---------+----------------------------------------------------------------
       7 | 101 Famous Poems. Alexander S. Pushkin, Ivan A. Bunin et al.
       4 | Dark Avenues. Ivan A. Bunin
       3 | Good Omens. Neil Gaiman, Terry Pratchett
       2 | Romeo and Juliet. William Shakespeare
       1 | The Tale of Tsar Saltan. Alexander S. Pushkin
       6 | Three Men in a Boat (To Say Nothing of the... Jerome K. Jerome
       5 | Travels into Several Remote Nations of the... Jonathan Swift
(7 rows)

Also remember to recreate the get_catalog function which was deleted:

Page 16: Page 16

=> CREATE FUNCTION get_catalog(
    author_name text,
    book_title text,
    in_stock boolean
)
RETURNS TABLE(book_id integer, display_name text, onhand_qty integer)
STABLE LANGUAGE sql
BEGIN ATOMIC
    SELECT cv.book_id,
           cv.display_name,
           cv.onhand_qty
    FROM   catalog_v cv
    WHERE  cv.title   ILIKE '%'||coalesce(book_title,'')||'%'
    AND    cv.authors ILIKE '%'||coalesce(author_name,'')||'%'
    AND    (in_stock AND cv.onhand_qty > 0 OR in_stock IS NOT TRUE)
    ORDER BY display_name;
END;
CREATE FUNCTION

2. A Pure SQL Version

=> CREATE OR REPLACE FUNCTION book_name(
    book_id integer,
    title text,
    maxauthors integer DEFAULT 2
)
RETURNS text
STABLE LANGUAGE sql
BEGIN ATOMIC
SELECT shorten(book_name.title) ||
       CASE WHEN (right(shorten(book_name.title), 3) != '...') THEN '. '::text ELSE ' ' END ||
       string_agg(
           author_name(a.last_name, a.first_name, a.middle_name), ', '
           ORDER BY ash.seq_num
       ) FILTER (WHERE ash.seq_num <= maxauthors) ||
       CASE
           WHEN max(ash.seq_num) > maxauthors THEN ' et al.'
           ELSE ''
       END
FROM   authors a
       JOIN authorship ash ON a.author_id = ash.author_id
WHERE  ash.book_id = book_name.book_id;
END;
CREATE FUNCTION
=> SELECT book_id, display_name FROM catalog_v;
 book_id |                          display_name
---------+----------------------------------------------------------------
       7 | 101 Famous Poems. Alexander S. Pushkin, Ivan A. Bunin et al.
       4 | Dark Avenues. Ivan A. Bunin
       3 | Good Omens. Neil Gaiman, Terry Pratchett
       2 | Romeo and Juliet. William Shakespeare
       1 | The Tale of Tsar Saltan. Alexander S. Pushkin
       6 | Three Men in a Boat (To Say Nothing of the... Jerome K. Jerome
       5 | Travels into Several Remote Nations of the... Jonathan Swift
(7 rows)

Page 17: Practice+

  1. Energy expenses must be distributed between different departments in proportion to their headcount (the list of departments is stored in a table). Create a function that takes the total energy cost as an argument and saves the distributed expenses in different table rows. The values are rounded to cents; the sum of expenses of all departments must exactly match the total cost. 2. Create a set returning function that simulates merge sort. The function should take two cursor variables; both cursors are already open and return sorted integers in non-decreasing order. It must return a single sorted sequence of integers from both sources.

Speaker notes

  1. We can use the following table:
CREATE TABLE depts(
  id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  employees integer,
  expenses numeric(10,2)
);
INSERT INTO depts(employees) VALUES (10),(10),(10);

A possible implementation:

FUNCTION distribute_expenses(amount numeric) RETURNS void;

If 100.00 is taken as an argument, the expected result is:

expenses
----------
 33.33
 33.34
 33.33
  1. A possible implementation:
FUNCTION merge(c1 refcursor, c2 refcursor) RETURNS SETOF integer;

For example, if the first cursor returns the sequence 1, 3, 5, and the second cursor returns the sequence 2, 3, 4, the expected result is as follows:

merge
-------
 1
 2
 3
 3
 4
 5

Page 18: 1. Expenses Distribution

=> CREATE DATABASE plpgsql_cursors;
CREATE DATABASE
=> \c plpgsql_cursors
You are now connected to database "plpgsql_cursors" as user "student".

Table:

=> CREATE TABLE depts(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    employees integer,
    expenses numeric(10,2)
);
CREATE TABLE
=> INSERT INTO depts(employees) VALUES (20),(10),(30);
INSERT 0 3

Function:

=> CREATE FUNCTION distribute_expenses(amount numeric) RETURNS void
AS $$
DECLARE
    depts_cur CURSOR FOR
        SELECT employees FROM depts FOR UPDATE;
    total_employees numeric;
    expense numeric;
    rounding_err numeric := 0.0;
    cent numeric;
BEGIN
    SELECT sum(employees) FROM depts INTO total_employees;
    FOR dept IN depts_cur LOOP
        expense := amount * (dept.employees / total_employees);
        rounding_err := rounding_err + (expense - round(expense,2));
        cent := round(rounding_err,2);
        expense := expense + cent;
        rounding_err := rounding_err - cent;
        UPDATE depts SET expenses = round(expense,2)
        WHERE CURRENT OF depts_cur;
    END LOOP;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Check:

=> SELECT distribute_expenses(100.0);
 distribute_expenses
---------------------
(1 row)
=> SELECT * FROM depts;
 id | employees | expenses
----+-----------+----------
  1 |        20 |    33.33
  2 |        10 |    16.67
  3 |        30 |    50.00
(3 rows)

Of course, other algorithms are also possible, for example, assigning all rounding errors to a single row, etc. The DEV2 course proposes another solution using custom aggregate functions.

2. Merging Sorted Sets

Page 19: Page 19

This implementation assumes that numbers cannot have undefined NULL values.

=> CREATE FUNCTION merge(c1 refcursor, c2 refcursor)
RETURNS SETOF integer
AS $$
DECLARE
    a integer;
    b integer;
BEGIN
    FETCH c1 INTO a;
    FETCH c2 INTO b;
    LOOP
        EXIT WHEN a IS NULL AND b IS NULL;
        IF a < b OR b IS NULL THEN
            RETURN NEXT a;
            FETCH c1 INTO a;
        ELSE
            RETURN NEXT b;
            FETCH c2 INTO b;
        END IF;
    END LOOP;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Test:

=> BEGIN;
BEGIN
=> DECLARE c1 CURSOR FOR
    SELECT * FROM (VALUES (1),(3),(5));
DECLARE CURSOR
=> DECLARE c2 CURSOR FOR
    SELECT * FROM (VALUES (2),(3),(4));
DECLARE CURSOR
=> SELECT * FROM merge('c1','c2');
 merge
-------
     1
     2
     3
     3
     4
     5
(6 rows)
=> COMMIT;
COMMIT
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_cursors;
DROP DATABASE

14 Lesson 14: PL/pgSQL / Dynamic Commands

Native Markdown source page: PL/pgSQL / Dynamic Commands
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_14_plpgsql_dynamic.html Archive: DEV1-handouts-16-en.zip PDF pages: 16

Page 1: PL/pgSQL / Dynamic Commands

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Objectives
  • Executing Dynamic Queries
  • Constructing Dynamic Commands

Page 3: Dynamic SQL

The text of an SQL query is constructed at run time

  • Objectives
    • provide additional flexibility for the application
    • construct several specific queries for optimization purposes
    • instead of using a single query that covers all possible cases
  • Trade-off
    • statements are not prepared
    • the risk of SQL injection rises
    • maintenance gets more complicated

Speaker notes

An SQL command is considered dynamic if its text is constructed and then executed within PL/pgSQL routine blocks or in anonymous blocks.

In most cases, you can do without dynamic commands, but sometimes they can provide additional flexibility. For example, an application can have a built-in capability to execute commands provided via system settings. Instead of being hard-coded by developers, these settings can be tuned by the support team in production.

When creating reports with a large number of optional parameters, it is sometimes easier to construct the text of a query at run time for the provided arguments only, instead of creating a complex query that includes all possible parameter combinations while developing the application.

The price you pay for using dynamic commands is inability to take advantage of prepared statements, which are used in PL/pgSQL by default. Besides, you have to take care of dynamic commands’ security as they are vulnerable to SQL injection.

We should also mention that maintenance gets more complicated. In particular, it will be impossible to scan the source code quickly for executable commands with tools like grep.

Page 4: Query Execution

EXECUTE construct

  • runs a text representation of an SQL query
  • allows using parameters
  • PL/pgSQL variables do not become implicit parameters
  • Can be used instead of an SQL query
    • independently
    • when opening a cursor
    • in a loop over a query
    • in the RETURN QUERY statement

Speaker notes

To run dynamic commands, PL/pgSQL uses the EXECUTE construct which launches the SQL command provided as a text string.

A dynamic query can contain explicit parameters. In the command’s text representation, parameters are denoted by $1, $2, etc.; their actual values are provided in the USING clause. Parameters are handled in the same way as in prepared statements (which is covered in Architecture. PostgreSQL Fundamentals). However, PL/pgSQL variables do not become implicit parameters, as it happens in the case of regular (as opposed to dynamic) use of SQL in PL/pgSQL.

The EXECUTE construct can be used as an independent statement (it will simply execute a dynamic command). It can also be used in loops over queries, when opening a cursor, or in the RETURN QUERY statement: in all these cases, EXECUTE replaces the SQL statement.

https://postgrespro.com/docs/postgresql/16/plpgsql-statements#PLPGSQL-STATEMENTS-EXECUTING-DYN

Note that a procedure cannot perform transaction control if it is called by an EXECUTE construct.

Page 5: Executing Dynamic Queries

The EXECUTE statement runs SQL commands provided as text.

=> DO $$
DECLARE
    cmd CONSTANT text := 'CREATE TABLE city_london(
        name text, architect text, founded integer
    )';
BEGIN
    EXECUTE cmd; -- a table that lists examples of contemporary architecture in London
END
$$;
DO

The INTO clause of the EXECUTE statement enables saving a single row of the result (the first returned row) into a variable of a composite type or into several scalar variables. As with static commands, you can check the result of a dynamic command using GET DIAGNOSTICS (but not the FOUND variable).

=> DO $$
DECLARE
    rec record;
    cnt bigint;
BEGIN
    EXECUTE 'INSERT INTO city_london (name, architect, founded) VALUES
                 (''The Shard'', ''Renzo Piano'', 2009),
                 (''The Scalpel'', ''Kohn Pedersen Fox'', 2018),
                 (''London Aquatics Centre'', ''Zaha Hadid'', 2011),
                 (''30 St Mary Axe'', ''Norman Foster'', 2001),
                 (''London City Hall'', ''Norman Foster'', 2000)
             RETURNING name, architect, founded'
    INTO rec;
    RAISE NOTICE '%', rec;
    GET DIAGNOSTICS cnt := ROW_COUNT;
    RAISE NOTICE 'Added rows: %', cnt;
END
$$;
NOTICE:  ("The Shard","Renzo Piano",2009)
NOTICE:  Added rows: 5
DO

You can use STRICT to ensure that the command processes only one row. The result of a dynamic query can be processed in a FOR loop.

=> DO $$
DECLARE
    rec record;
BEGIN
    FOR rec IN EXECUTE 'SELECT * FROM city_london WHERE architect = ''Norman Foster'' ORDER BY founded'
    LOOP
        RAISE NOTICE '%', rec;
    END LOOP;
END
$$;
NOTICE:  ("London City Hall","Norman Foster",2000)
NOTICE:  ("30 St Mary Axe","Norman Foster",2001)
DO

Here is the same example using a cursor:

Page 6: Page 6

=> DO $$
DECLARE
    cur refcursor;
    rec record;
BEGIN
    OPEN cur FOR EXECUTE 'SELECT * FROM city_london WHERE architect = ''Norman Foster'' ORDER BY founded';
    LOOP
        FETCH cur INTO rec;
        EXIT WHEN NOT FOUND;
        RAISE NOTICE '%', rec;
    END LOOP;
END
$$;
NOTICE:  ("London City Hall","Norman Foster",2000)
NOTICE:  ("30 St Mary Axe","Norman Foster",2001)
DO

The RETURN QUERY statement can also use dynamic queries to return rows from functions. Let’s create a function that retrieves all buildings constructed by a particular architect and up to the specified year. We will have to use parameters for this purpose:

=> CREATE FUNCTION sel_london(architect text, founded integer DEFAULT NULL)
RETURNS SETOF text
AS $$
DECLARE
    -- parameters are numbered: $1, $2...
    cmd text := '
        SELECT name FROM city_london
        WHERE architect = $1 AND ($2 IS NULL OR founded <= $2)';
BEGIN
    RETURN QUERY
        EXECUTE cmd
        USING architect, founded; -- provide parameters in the order of their declaration
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT * FROM sel_london('Norman Foster');
    sel_london
------------------
 30 St Mary Axe
 London City Hall
(2 rows)
=> SELECT * FROM sel_london('Norman Foster', 2000);
    sel_london
------------------
 London City Hall
(1 row)

Page 7: Constructing Commands

Parameter values binding

  • USING clause
  • guarantees protection against SQL injection
  • Escaping values
    • identifiers: format(’%I’), quote_ident
    • literals: format(’%L’), quote_literal, quote_nullable
    • SQL injection is impossible if implemented correctly
  • Regular string functions
    • concatenation, etc.
    • risk of SQL injection!

Speaker notes

Using the EXECUTE construct makes sense if the command is constructed dynamically. The previous examples could also do without EXECUTE.

Since the command is represented by a text string, it can be constructed using regular string functions that perform such operations as concatenation, etc. This should be done with great care as there is a risk of SQL injection.

If the values are passed as parameters in the USING clause, SQL injection is technically impossible.

However, it is not always possible to use parameters: you may have to concatenate specific parts of the query or insert a table name into the query. In this case, you should escape the values received from an unreliable source to protect your application against injections.

Identifiers are generated by either the format function with the %l specifier or the quote_ident function. These functions ensure that identifiers have valid names by double-quoting them and escaping special characters, if required.

To insert literals into the command text, you can use either quote_literal
and quote_nullable functions or the format function with the %L specifier.

https://postgrespro.com/docs/postgresql/16/functions-string

Page 8: Dealing with SQL Injection

Let’s rewrite the function returning buildings and add one more parameter: the name of the city. The idea is to allow this function to access tables only if their names start with city_.

=> CREATE FUNCTION sel_city(
    city_code text,
    architect text,
    founded integer DEFAULT NULL
)
RETURNS SETOF text AS $$
DECLARE
    cmd text := '
        SELECT name FROM city_' || city_code || '
        WHERE architect = $1 AND ($2 IS NULL OR founded < $2)';
BEGIN
    RAISE NOTICE '%', cmd;
    RETURN QUERY
        EXECUTE cmd
        USING architect, founded;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

The function works fine if its parameter values are “correct”:

=> SELECT * FROM sel_city('london', 'Renzo Piano');
NOTICE:
        SELECT name FROM city_london
        WHERE architect = $1 AND ($2 IS NULL OR founded < $2)
 sel_city
-----------
 The Shard
(1 row)

But a malicious user can pick a value that will change the syntactic structure of the query and enable unauthorized access to data:

=> SELECT * FROM sel_city('london WHERE false
        UNION ALL
        SELECT usename FROM pg_user
        UNION ALL
        SELECT name FROM city_london', '');
NOTICE:
        SELECT name FROM city_london WHERE false
        UNION ALL
        SELECT usename FROM pg_user
        UNION ALL
        SELECT name FROM city_london
        WHERE architect = $1 AND ($2 IS NULL OR founded < $2)
 sel_city
----------
 student
 postgres
(2 rows)

When you are using prepared statements or dynamic commands with parameters, such a situation is technically impossible because the structure of the SQL query is locked while the statement is parsed. An expression will always remain an expression; it is impossible to convert it, say, into a table name.

Constructing a Dynamic Command

It is impossible to provide the names of objects (such as tables or columns) as parameters of the USING clause in a dynamic command. Such identifiers must be escaped, so that it is impossible to modify the query structure:

=> SELECT format('%I', 'foo'),
          format('%I', 'foo bar'),
          format('%I', 'foo"bar');

Page 9: Page 9

 format |  format   |   format
--------+-----------+------------
 foo    | "foo bar" | "foo""bar"
(1 row)

The following function does the same thing:

=> SELECT quote_ident('foo'),
          quote_ident('foo bar'),
          quote_ident('foo"bar');
 quote_ident | quote_ident | quote_ident
-------------+-------------+-------------
 foo         | "foo bar"   | "foo""bar"
(1 row)

Here is an example of creating a table:

=> DO $$
DECLARE
    cmd CONSTANT text := 'CREATE TABLE %I(
        name text, architect text, founded integer
    )';
BEGIN
    EXECUTE format(cmd, 'city_paris'); -- a table for Paris
    EXECUTE format(cmd, 'city_milan'); -- a table for Milan
END
$$;
DO

Instead of using parameters, you can insert literals into a string. It also requires escaping, but in a bit different way:

=> SELECT format('%L', 'foo bar'),
          format('%L', 'foo''bar'),
          format('%L', NULL);
  format   |   format   | format
-----------+------------+--------
 'foo bar' | 'foo''bar' | NULL
(1 row)

The quote_nullable function also does the same thing:

=> SELECT quote_nullable('foo bar'),
          quote_nullable('foo''bar'),
          quote_nullable(NULL);
 quote_nullable | quote_nullable | quote_nullable
----------------+----------------+----------------
 'foo bar'      | 'foo''bar'     | NULL
(1 row)

The quote_literal function is quite similar, but it does not convert NULL values into literals:

=> SELECT quote_literal(NULL);
 quote_literal
---------------
(1 row)

As an example, let’s rewrite the function that returns the list of buildings of a particular city, so that it does not use any parameters, but still remains safe.

Page 10: Page 10

=> CREATE OR REPLACE FUNCTION sel_city(
    city_code text,
    architect text,
    founded integer DEFAULT NULL
)
RETURNS SETOF text
AS $$
DECLARE
    cmd text := '
        SELECT name FROM %I
        WHERE architect = %L AND (%L IS NULL OR founded < %L::integer)';
BEGIN
    RETURN QUERY EXECUTE format(
        cmd, 'city_'||city_code, architect, founded, founded
    );
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
Note

Note Note that we perform two extra type casting operations: first, the integer parameter is converted into a string, and then it is cast back to integer at run time (this can be avoided with USING parameters):

=> SELECT * FROM sel_city('london', 'Renzo Piano', 2009);
 sel_city
----------
(0 rows)

An attempt to pass an invalid value will not succeed:

=> SELECT * FROM sel_city('london WHERE false
        UNION ALL
        SELECT usename FROM pg_user
        UNION ALL
        SELECT name FROM city_london', '');
NOTICE:  identifier "city_london WHERE false
        UNION ALL
        SELECT usename FROM pg_user
        UNION ALL
        SELECT name FROM city_london" will be truncated to "city_london WHERE false
        UNION ALL
        SELECT usenam"
ERROR:  relation "city_london WHERE false
        UNION ALL
        SELECT usenam" does not exist
LINE 2:         SELECT name FROM "city_london WHERE false
                                 ^
QUERY:
        SELECT name FROM "city_london WHERE false
        UNION ALL
        SELECT usename FROM pg_user
        UNION ALL
        SELECT name FROM city_london"
        WHERE architect = '' AND (NULL IS NULL OR founded < NULL::integer)
CONTEXT:  PL/pgSQL function sel_city(text,text,integer) line 7 at RETURN QUERY

Page 11: Takeaways

Dynamic commands provide additional flexibility

  • Constructing separate queries for different arguments can improve performance
  • Dynamic commands are not suitable for short, frequent queries
  • Maintenance gets more complicated

Page 12: Practice

  1. Modify the get_catalog function so that the query to the catalog_v view is constructed dynamically and takes into account only those fields that are filled out in the search form of the Store tab. Make sure that your implementation is protected against SQL injection. Check your function in the application.

Speaker notes

  1. Suppose we have to generate the following query if these conditions are met: the “In stock” option is selected in the search form, but “Book Title” and “Author” fields are empty.
SELECT ... FROM catalog_v WHERE onhand_qty > 0;

You should keep in mind that this implementation will not necessarily speed up search, but it will certainly be harder to maintain. Avoid such solutions in production environments unless you have a solid reason to use this technique. To learn more about query performance tuning, check out the QPT course.

Page 13: 1. The get_catalog Function

=> CREATE OR REPLACE FUNCTION get_catalog(
    author_name text,
    book_title text,
    in_stock boolean
)
RETURNS TABLE(book_id integer, display_name text, onhand_qty integer)
AS $$
DECLARE
    title_cond text := '';
    author_cond text := '';
    qty_cond text := '';
    cmd text;
BEGIN
    IF book_title != '' THEN
        title_cond := format(
            ' AND cv.title ILIKE %L', '%'||book_title||'%'
        );
    END IF;
    IF author_name != '' THEN
        author_cond := format(
            ' AND cv.authors ILIKE %L', '%'||author_name||'%'
        );
    END IF;
    IF in_stock THEN
        qty_cond := ' AND cv.onhand_qty > 0';
    END IF;
    cmd := 'SELECT cv.book_id,
               cv.display_name,
               cv.onhand_qty
        FROM   catalog_v cv
        WHERE  true'
        || title_cond || author_cond || qty_cond || '
        ORDER BY display_name';
    RAISE NOTICE '%', cmd;
    RETURN QUERY EXECUTE cmd;
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

Page 14: Practice+

  1. Create a function that returns a matrix report on functions available in the database.
  • The columns must contain names of function owners, the rows
  • must provide schema names, while the cells must display the
  • number of functions that belong to a particular owner in a
  • particular schema.
  • What statement should you write to invoke this function?

Speaker notes

  1. Here is a possible output:
 schema             total postgres student ...
 information_schema    12       12       0
 pg_catalog          2811     2811       0
 public                 3        0       3
 ...

The number of columns returned by a query is unknown beforehand. You need to construct a query and then execute it dynamically. The query text can be as follows:

SELECT pronamespace::regnamespace::text AS schema,
    COUNT(*) AS total
   ,SUM(CASE WHEN proowner = 10 THEN 1 ELSE 0 END) postgres
   ,SUM(CASE WHEN proowner = 16384 THEN 1 ELSE 0 END) student
FROM pg_proc
GROUP BY pronamespace::regnamespace
ORDER BY schema

The highlighted lines are a dynamic part that has to be constructed by a separate query. The start and the end of the query are static.

The proowner column has the oid type. To get the name of the owner, you can use the following construct: proowner::regrole::text.

Page 15: Building a Matrix Report

=> CREATE DATABASE plpgsql_dynamic;
CREATE DATABASE
=> \c plpgsql_dynamic
You are now connected to database "plpgsql_dynamic" as user "student".

A function for generating dynamic query text:

=> CREATE FUNCTION form_query() RETURNS text
AS $$
DECLARE
    query_text text;
    columns text := '';
    r record;
BEGIN
    -- Static part of the query.
    -- First two columns: schema name and number of functions
    query_text :=
$query$
SELECT pronamespace::regnamespace::text AS schema
     , count(*) AS total{{columns}}
FROM pg_proc
GROUP BY pronamespace::regnamespace
ORDER BY schema
$query$;
    -- Dynamic part of the query.
    -- Get a list of function owners, a separate column for each
    FOR r IN SELECT DISTINCT proowner AS owner FROM pg_proc ORDER BY 1
    LOOP
        columns := columns || format(
            E'\n     , sum(CASE WHEN proowner = %s THEN 1 ELSE 0 END) AS %I',
            r.owner,
            r.owner::regrole
        );
    END LOOP;
    RETURN replace(query_text, '{{columns}}', columns);
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

The resulting query text:

=> SELECT form_query();
                             form_query
---------------------------------------------------------------------
                                                                    +
 SELECT pronamespace::regnamespace::text AS schema                  +
      , count(*) AS total                                           +
      , sum(CASE WHEN proowner = 10 THEN 1 ELSE 0 END) AS postgres  +
      , sum(CASE WHEN proowner = 16384 THEN 1 ELSE 0 END) AS student+
 FROM pg_proc                                                       +
 GROUP BY pronamespace::regnamespace                                +
 ORDER BY schema                                                    +
(1 row)

Now create a matrix report function:

=> CREATE FUNCTION matrix() RETURNS SETOF record
AS $$
BEGIN
    RETURN QUERY EXECUTE form_query();
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

Simply executing the query will result in an error, as the structure of the returned records is not specified:

Page 16: Page 16

=> SELECT * FROM matrix();
ERROR:  a column definition list is required for functions returning "record"
LINE 1: SELECT * FROM matrix();
                      ^

This is an important limitation on using functions that return an arbitrary set of values. You need to know and specify the structure of the record to be returned as the function is being called. In general, the returned record structure may be unknown, but for our matrix report, we can run another query that will show you how to call the matrix function correctly. Prepare the query text:

=> CREATE FUNCTION matrix_call() RETURNS text
AS $$
DECLARE
    cmd text;
    r record;
BEGIN
    cmd := 'SELECT * FROM matrix() AS (
    schema text, total bigint';
    FOR r IN SELECT DISTINCT proowner AS owner FROM pg_proc ORDER BY 1
    LOOP
        cmd := cmd || format(', %I bigint', r.owner::regrole::text);
    END LOOP;
    cmd := cmd || E'\n)';
    RAISE NOTICE '%', cmd;
    RETURN cmd;
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

Now we can use matrix_call to get a query that reflects the structure of the matrix report, and then execute this query and get the report (psql allows you to do all this with single \\gexec command):

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT matrix_call() \gexec
NOTICE:  SELECT * FROM matrix() AS (
    schema text, total bigint, postgres bigint, student bigint
)
       schema       | total | postgres | student
--------------------+-------+----------+---------
 information_schema |    11 |       11 |       0
 pg_catalog         |  3286 |     3286 |       0
 public             |     3 |        0 |       3
(3 rows)
=> COMMIT;
COMMIT

The matrix report is generated correctly. Repeatable Read isolation level guarantees that the report will be generated, even if a function with a new owner will come up between two queries. The query returned by form_query could have been run manually. However, you still need to return the list of columns to the client. The matrix_call function demonstrates how to solve this with an extra query. Another solution is to return a set of strings of a semi-structured type (such as JSON or XML) instead of an arbitrary structured set. These types are covered in the DEV2 course.

=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_dynamic;
DROP DATABASE

15 Lesson 15: PL/pgSQL / Arrays

Native Markdown source page: PL/pgSQL / Arrays
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_15_plpgsql_arrays.html Archive: DEV1-handouts-16-en.zip PDF pages: 24

Page 1: PL/pgSQL / Arrays

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Arrays and Their Usage in PL/pgSQL
  • Loops over Array Elements
  • Functions with a Variable Number of Arguments and Polymorphic Functions
  • Array Usage in Tables

Page 3: Array Types

Array

  • a set of numbered elements of the same type
  • one-dimensional, multidimensional
  • Initialization
usage without an explicit declaration (type_name[])
implicit declaration when creating a base type or a table (_type_name)
  • Usage
    • elements as scalar values
    • array slices
    • operations on arrays: comparison, inclusion, intersection, concatenation,
    • usage with ANY or ALL instead of a subquery, etc.

Speaker notes

Just like a composite type (a record), an array is not a scalar; it consists of several elements of another type. But unlike in records, a) all these elements are of the same type, and b) they are accessed by an integer index, not by name (here the term index is used in the mathematical sense of the word, not in the sense of a database index).

An array type does not have to be explicitly declared. When any type (or a table) is created, a new array type named _type is also declared. To declare an array variable, all you need is to append square brackets to the name of the element type.

An array is a full-fledged SQL type: you can create table columns of this type, pass arrays as function arguments, and so on. Array elements can be used as regular scalar values. Array slices can also be used.

Arrays can be compared and checked for NULL; you can search arrays for element inclusion and intersection with other arrays, perform concatenation, etc. Arrays can also be applied in ANY/SOME and ALL constructs, similar to subqueries.

https://postgrespro.com/docs/postgresql/16/arrays

You can find various array functions in course handouts.

Page 4: Initializing an Array and Referencing its Elements

Declaring a variable and initializing an array:

=> DO $$
DECLARE
    a integer[2]; -- the size is ignored
BEGIN
    a := ARRAY[10,20,30];
    RAISE NOTICE '%', a;
    -- by default, one-based indexing is used
    RAISE NOTICE 'a[1] = %, a[2] = %, a[3] = %', a[1], a[2], a[3];
    -- array slice
    RAISE NOTICE 'Slice [2:3] = %', a[2:3];
    -- assign values to the array slice
    a[2:3] := ARRAY[222,333];
    -- output the array
    RAISE NOTICE '%', a;
END;
$$ LANGUAGE plpgsql;
NOTICE:  {10,20,30}
NOTICE:  a[1] = 10, a[2] = 20, a[3] = 30
NOTICE:  Slice [2:3] = {20,30}
NOTICE:  {10,222,333}
DO

A one-dimensional array can be constructed element by element: it will be expanded automatically if required. If you omit some of the elements, they will receive NULL values. What will be displayed?

=> DO $$
DECLARE
    a integer[];
BEGIN
    a[2] := 10;
    a[3] := 20;
    a[6] := 30;
    RAISE NOTICE '%', a;
END;
$$ LANGUAGE plpgsql;
NOTICE:  [2:6]={10,20,NULL,NULL,30}
DO

Since element numbering begins with a value other than one, the array itself is preceded by a range of element indexes. We can define a composite type and create an array of this type:

=> CREATE TYPE currency AS (amount numeric, code text);
CREATE TYPE
=> DO $$
DECLARE
    c currency[];  -- composite type array
BEGIN
    -- assign values to array elements
    c[1].amount := 10;  c[1].code := 'RUB';
    c[2].amount := 50;  c[2].code := 'KZT';
    RAISE NOTICE '%', c;
END
$$ LANGUAGE plpgsql;
NOTICE:  {"(10,RUB)","(50,KZT)"}
DO

Another way to construct an array is to use a subquery:

=> DO $$
DECLARE
    a integer[];
BEGIN
    a := ARRAY( SELECT n FROM generate_series(1,3) n );
    RAISE NOTICE '%', a;
END
$$ LANGUAGE plpgsql;

Page 5: Page 5

NOTICE:  {1,2,3}
DO

You can also do the inverse operation: convert an array into a table:

=> SELECT unnest( ARRAY[1,2,3] );
 unnest
--------
      1
      2
      3
(3 rows)

Fun fact: the IN clause with a list of values is transformed into a search operation over the array:

=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) g(id) WHERE id IN (1,2,3);
                 QUERY PLAN
---------------------------------------------
 Function Scan on generate_series g
   Filter: (id = ANY ('{1,2,3}'::integer[]))
(2 rows)

A two-dimensional array is a rectangle matrix. Memory for the matrix is allocated at initialization. A literal looks like an array of arrays with the same number of elements. Here we have applied another initialization method that uses a string literal. Once initialized, a multidimensional array cannot be expanded.

=> DO $$
DECLARE
    a integer[][] := '{
        { 10, 20, 30},
        {100,200,300}
    }';
BEGIN
    RAISE NOTICE 'Two-dimensional array : %', a;
    RAISE NOTICE 'Unlimited array slice [2:] = %', a[2:];
    -- assign values to the slice
    a[2:] := ARRAY[ARRAY[111, 222, 333]];
    -- output the whole array again
    RAISE NOTICE '%', a;
    -- cannot be expanded in any dimension
    a[4][4] := 1;
END
$$ LANGUAGE plpgsql;
NOTICE:  Two-dimensional array : {{10,20,30},{100,200,300}}
NOTICE:  Unlimited array slice [2:] = {{100,200,300}}
NOTICE:  {{10,20,30},{111,222,333}}
ERROR:  array subscript out of range
CONTEXT:  PL/pgSQL function inline_code_block line 15 at assignment

Page 6: Arrays and Loops

A regular loop over element indexes

  • array_lower
  • array_upper
  • A FOREACH loop over array elements
    • this approach is easier, but it does not provide access to indexes

Speaker notes

To iterate through array elements, you can simply set up an integer FOR loop using functions that return the minimum and the maximum index of the array.

But there is also a specialized loop: FOREACH. In this case, a loop variable iterates through the elements, not their indexes. That’s why the variable must be of the same type as the array elements (as always, if the elements are records, you can replace a single composite variable with several scalar ones).

If a loop contains the SLICE clause, it will iterate through array slices. For example, the rows of a two-dimensional array will be treated as its one-dimensional slices.

https://postgrespro.com/docs/postgresql/16/plpgsql-control-structures#PLP GSQL-FOREACH-ARRAY

Page 7: Arrays and Loops

You can set up a loop that iterates through index values of array elements. The second parameter of the array_lower and array_upper functions defines the array dimension (1 denotes a one-dimensional array).

=> DO $$
DECLARE
    a integer[] := ARRAY[10,20,30];
BEGIN
    FOR i IN array_lower(a,1)..array_upper(a,1) LOOP
        RAISE NOTICE 'a[%] = %', i, a[i];
    END LOOP;
END
$$ LANGUAGE plpgsql;
NOTICE:  a[1] = 10
NOTICE:  a[2] = 20
NOTICE:  a[3] = 30
DO

If you do not need to know index values, it’s easier to iterate directly though the elements:

=> DO $$
DECLARE
    a integer[] := ARRAY[10,20,30];
    x integer;
BEGIN
    FOREACH x IN ARRAY a LOOP
        RAISE NOTICE '%', x;
    END LOOP;
END
$$ LANGUAGE plpgsql;
NOTICE:  10
NOTICE:  20
NOTICE:  30
DO

Index iteration in a two-dimensional array:

=> DO $$
DECLARE
    -- double square brackets are optional
    a integer[] := ARRAY[
        ARRAY[ 10, 20, 30],
        ARRAY[100,200,300]
];
BEGIN
    FOR i IN array_lower(a,1)..array_upper(a,1) LOOP -- over rows
        FOR j IN array_lower(a,2)..array_upper(a,2) LOOP -- over columns
            RAISE NOTICE 'a[%][%] = %', i, j, a[i][j];
        END LOOP;
    END LOOP;
END
$$ LANGUAGE plpgsql;
NOTICE:  a[1][1] = 10
NOTICE:  a[1][2] = 20
NOTICE:  a[1][3] = 30
NOTICE:  a[2][1] = 100
NOTICE:  a[2][2] = 200
NOTICE:  a[2][3] = 300
DO

You do not need a nested loop to iterate through the elements of a two-dimensional array:

Page 8: Page 8

=> DO $$
DECLARE
    a integer[] := ARRAY[
        ARRAY[ 10, 20, 30],
        ARRAY[100,200,300]
    ];
    x integer;
BEGIN
    FOREACH x IN ARRAY a LOOP
        RAISE NOTICE '%', x;
    END LOOP;
END
$$ LANGUAGE plpgsql;
NOTICE:  10
NOTICE:  20
NOTICE:  30
NOTICE:  100
NOTICE:  200
NOTICE:  300
DO

You can also loop through slices, not just individual elements, in a similar way. The SLICE value must be an integer, no larger than the array size, and the variable to store the slices into must be an array itself. Here is a loop over one-dimensional slices as an example:

=> DO $$
DECLARE
    a integer[] := ARRAY[
        ARRAY[ 10, 20, 30],
        ARRAY[100,200,300]
    ];
    x integer[];
BEGIN
    FOREACH x SLICE 1 IN ARRAY a LOOP
        RAISE NOTICE '%', x;
    END LOOP;
END
$$ LANGUAGE plpgsql;
NOTICE:  {10,20,30}
NOTICE:  {100,200,300}
DO

Page 9: Arrays and Routines

Routines with a variable number of arguments

  • all optional arguments must be of the same type
  • optional arguments are passed to the routine as an array
  • the last parameter array is defined by the VARIADIC mode
  • Polymorphic routines
    • support arguments of various types;
    • the actual type is defined at run time
    • can use additional polymorphic pseudotypes anyarray, anynonarray,
    • anycompatiblearray and anycompatiblenonarrray
    • can have a variable number of arguments

Speaker notes

Using arrays, you can create routines (functions and procedures) with a variable number of arguments.

While parameters with default values have to be explicitly specified in routine declaration, optional arguments can be passed with no limit: they are provided as an array. Consequently, all of them must be of the same type (or a compatible one, if anycompatible or anycompatiblearray is used).

The last parameter in routine declaration must be marked as VARIADIC; it must be of an array type.

https://postgrespro.com/docs/postgresql/16/xfunc-sql#XFUNC-SQL-VARIAD IC-FUNCTIONS

We have already mentioned polymorphic routines which can accept arguments of various types. Routine declaration uses a special polymorphic pseudotype, while the actual type is defined at run time based on the types of the passed arguments.

There are special polymorphic types anyarray and anycompatiblearray (and anynonarray and anycompatiblenonarrray for non-arrays).

These types can be used when passing a variable number of arguments via a VARIADIC parameter.

https://postgrespro.com/docs/postgresql/16/xfunc-sql#XFUNC-SQL-POLYM ORPHIC-FUNCTIONS

Page 10: Arrays and Routines

When discussing polymorphism and overloading as part of the SQL. Procedures lecture, we created the maximum function to compare three numbers and find the greatest one. Now let’s generalize this function, so that it can be used with an arbitrary number of arguments. For this purpose, we’ll declare a single VARIADIC parameter:

=> CREATE FUNCTION maximum(VARIADIC a integer[]) RETURNS integer
AS $$
DECLARE
    x integer;
    maxsofar integer;
BEGIN
    FOREACH x IN ARRAY a LOOP
        IF x IS NOT NULL AND (maxsofar IS NULL OR x > maxsofar) THEN
            maxsofar := x;
        END IF;
    END LOOP;
    RETURN maxsofar;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s try it out:

=> SELECT maximum(12, 65, 47);
 maximum
---------
      65
(1 row)
=> SELECT maximum(12, 65, 47, null, 87, 24);
 maximum
---------
      87
(1 row)
=> SELECT maximum(null, null);
 maximum
---------
(1 row)

To complete this illustration, we can make this function polymorphic as well, so that it takes any data type (which supports comparison operators, of course).

=> DROP FUNCTION maximum(integer[]);
DROP FUNCTION

Polymorphic types anycompatiblearray and anycompatible (also anyarray and anyelement) must match each other: anycompatiblearray = anycompatible[], anyarray = anyelement[]; We need a variable of the same type as an array element. But it cannot be declared as anycompatible: it must have an actual type. The %TYPE construct helps us out here.

=> CREATE FUNCTION maximum(VARIADIC a anycompatiblearray, maxsofar OUT anycompatible)
AS $$
DECLARE
    x maxsofar%TYPE;
BEGIN
    FOREACH x IN ARRAY a LOOP
        IF x IS NOT NULL AND (maxsofar IS NULL OR x > maxsofar) THEN
            maxsofar := x;
        END IF;
    END LOOP;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s check the result:

Page 11: Page 11

=> SELECT maximum(12, 65, 47);
 maximum
---------
      65
(1 row)
=> SELECT maximum(12.1, 65.3, 47.6);
 maximum
---------
    65.3
(1 row)
=> SELECT maximum(12, 65.3, 15e2, 3.14);
 maximum
---------
    1500
(1 row)

Now our function is almost completely analogous to the greatest expression provided in SQL.

Page 12: An Array or a Table?

Source slide page 12: An Array or a Table?{#source-figure-012 caption=“Source slide page 12: An Array or a Table?”}

1
...
{A}
2
...
{B,C,D}
3
...
{A,C}
1
1
1
A
...
2
2
1
...
2
B
...
2
3
2
...
2
4
3
C
...
3
...
3
1
4
D
...
3
3

Speaker notes

A traditional relational approach assumes that a table stores atomic values (first normal form). The SQL language has no tools for peeking into composite values.

That’s why a traditional approach relies on creating an additional table connected to the main one by a many-to-many relationship.

Nevertheless, we can create a table with a column of an array type. PostgreSQL offers a rich set of array functions; the search for an array element can be sped up using special indexes (covered in the DEV2 course).

This approach can be convenient: we get a concise representation that does not require any joins. For example, arrays are extensively used in PostgreSQL system catalog.

The choice of approach depends on the goals and the operations required. Consider the example in the demo.

Page 13: An Array or a Table?

Imagine that we are designing a database for writing a blog. The blog contains some posts, and we would like to tag them. The traditional approach is to create a separate table for tags. For example:

=> CREATE TABLE posts(
    post_id integer PRIMARY KEY,
    message text
);
CREATE TABLE
=> CREATE TABLE tags(
    tag_id integer PRIMARY KEY,
    name text
);
CREATE TABLE

Let’s connect posts and tags by a many-to-many relationship via an additional table:

=> CREATE TABLE posts_tags(
    post_id integer REFERENCES posts(post_id),
    tag_id integer REFERENCES tags(tag_id)
);
CREATE TABLE

Let’s fill our tables with text data:

=> INSERT INTO posts(post_id,message) VALUES
    (1, 'I set my password to “incorrect”.'),
    (2, 'Your future is whatever you make it, so make it a good one.');
INSERT 0 2
=> INSERT INTO tags(tag_id,name) VALUES
    (1, 'my past and thoughts'), (2, 'technology'), (3, 'family');
INSERT 0 3
=> INSERT INTO posts_tags(post_id,tag_id) VALUES
    (1,1), (1,2), (2,1), (2,3);
INSERT 0 4

Now we can display posts and tags:

=> SELECT p.message, t.name
FROM posts p
     JOIN posts_tags pt ON pt.post_id = p.post_id
     JOIN tags t ON t.tag_id = pt.tag_id
ORDER BY p.post_id, t.name \gx
-[ RECORD 1 ]--------------------------------------------------------
message | I set my password to “incorrect”.
name    | my past and thoughts
-[ RECORD 2 ]--------------------------------------------------------
message | I set my password to “incorrect”.
name    | technology
-[ RECORD 3 ]--------------------------------------------------------
message | Your future is whatever you make it, so make it a good one.
name    | family
-[ RECORD 4 ]--------------------------------------------------------
message | Your future is whatever you make it, so make it a good one.
name    | my past and thoughts

Or we can do it a bit differently to get an array of tags. We are going to use an aggregate function for this purpose:

=> SELECT p.message, array_agg(t.name ORDER BY t.name) tags
FROM posts p
    JOIN posts_tags pt ON pt.post_id = p.post_id
    JOIN tags t ON t.tag_id = pt.tag_id
GROUP BY p.post_id
ORDER BY p.post_id \gx

Page 14: Page 14

-[ RECORD 1 ]--------------------------------------------------------
message | I set my password to “incorrect”.
tags    | {"my past and thoughts",technology}
-[ RECORD 2 ]--------------------------------------------------------
message | Your future is whatever you make it, so make it a good one.
tags    | {family,"my past and thoughts"}

We can find all posts with a particular tag:

=> SELECT p.message
FROM posts p
    JOIN posts_tags pt ON pt.post_id = p.post_id
    JOIN tags t ON t.tag_id = pt.tag_id
WHERE t.name = 'my past and thoughts'
ORDER BY p.post_id;
                           message
-------------------------------------------------------------
 I set my password to “incorrect”.
 Your future is whatever you make it, so make it a good one.
(2 rows)

We may also need to find all the unique tags, and it is really easy:

=> SELECT t.name
FROM tags t
ORDER BY t.name;
         name
----------------------
 family
 my past and thoughts
 technology
(3 rows)

Now let’s try another approach to this task. Suppose the tags are stored as a text array right inside the table with posts.

=> DROP TABLE posts_tags;
DROP TABLE
=> DROP TABLE tags;
DROP TABLE
=> ALTER TABLE posts ADD COLUMN tags text[];
ALTER TABLE

There are no tag IDs, but we don’t really need them.

=> UPDATE posts SET tags = '{"my past and thoughts","technology"}'
WHERE post_id = 1;
UPDATE 1
=> UPDATE posts SET tags = '{"my past and thoughts","family"}'
WHERE post_id = 2;
UPDATE 1

Now it’s easier to display all posts:

=> SELECT p.message, p.tags
FROM posts p
ORDER BY p.post_id \gx
-[ RECORD 1 ]--------------------------------------------------------
message | I set my password to “incorrect”.
tags    | {"my past and thoughts",technology}
-[ RECORD 2 ]--------------------------------------------------------
message | Your future is whatever you make it, so make it a good one.
tags    | {"my past and thoughts",family}

It is also easy to find all posts with the same tag (using the intersection operator &&). This operation can be sped up using GIN index, and the query won’t require searching through the whole table of posts.

Page 15: Page 15

=> SELECT p.message
FROM posts p
WHERE p.tags && '{"my past and thoughts"}'
ORDER BY p.post_id;
                           message
-------------------------------------------------------------
 I set my password to “incorrect”.
 Your future is whatever you make it, so make it a good one.
(2 rows)

But it is quite hard to get the list of all tags. It requires unnesting all the tag arrays into a big table, and the search for unique values in this table is quite resource-intensive.

=> SELECT DISTINCT unnest(p.tags) AS name
FROM posts p;
         name
----------------------
 family
 technology
 my past and thoughts
(3 rows)

We can clearly see data duplication here. Thus, both approaches have the right to be applied. In simple cases, arrays look more straightforward and work well. In more complex scenarios (imagine that we would like to store the date of tag creation together with its name, or we need to use check constraints), the traditional approach becomes more preferable.

Page 16: Takeaways

An array consists of numbered elements of the same data type

  • An array column is an alternative to a separate table: it offers convenient operations on arrays and index support
  • Arrays enable you to create functions with a variable number of arguments

Page 17: Practice

  1. Create a function add_book for adding a new book.
  • The function must take two arguments: the name of the book and
  • an array of author IDs. It must return the ID of the added book.
  • Check that the application now allows adding books.
FUNCTION add_book(title text, authors integer[])
RETURNS integer

Page 18: 1. The add_book Function

=> CREATE FUNCTION add_book(title text, authors integer[])
RETURNS integer
AS $$
DECLARE
    book_id integer;
    id integer;
    seq_num integer := 1;
BEGIN
    INSERT INTO books(title)
        VALUES(title)
        RETURNING books.book_id INTO book_id;
    FOREACH id IN ARRAY authors LOOP
        INSERT INTO authorship(book_id, author_id, seq_num)
            VALUES (book_id, id, seq_num);
        seq_num := seq_num + 1;
    END LOOP;
    RETURN book_id;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Page 19: Practice+

  1. Implement a function map that takes two arguments: array of real numbers and the name of an additional function that takes a single parameter of the real type.
  • The map function must apply the additional function to each
  • element of the array and return the result.
    1. Implement a function reduce that takes two arguments: an array of real numbers and the name of an additional function that takes two arguments of the real type.
    • The function must return a real number calculated by
    • sequentially folding the array from left to right.
    1. Make the functions map and reduce polymorphic.

Speaker notes

  1. For example:
map(ARRAY[4.0,9.0],'sqrt') → ARRAY[2.0,3.0]
  1. For example:
reduce(ARRAY[1.0,3.0,2.0,0.5],'greatest') → 3.0

In this case, the value is calculated as follows:

greatest( greatest( greatest(1.0,3.0), 2.0 ), 0.5 )

Page 20: 1. The map Function

=> CREATE DATABASE plpgsql_arrays;
CREATE DATABASE
=> \c plpgsql_arrays
You are now connected to database "plpgsql_arrays" as user "student".
=> CREATE FUNCTION map(a INOUT float[], func text)
AS $$
DECLARE
    i integer;
    x float;
BEGIN
    IF cardinality(a) > 0 THEN
        FOR i IN array_lower(a,1)..array_upper(a,1) LOOP
            EXECUTE format('SELECT %I($1)',func) USING a[i] INTO x;
            a[i] := x;
        END LOOP;
    END IF;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

INTO a[i] doesn’t work, need a separate variable.

=> SELECT map(ARRAY[4.0,9.0,16.0],'sqrt');
   map
---------
 {2,3,4}
(1 row)
=> SELECT map(ARRAY[]::float[],'sqrt');
 map
-----
 {}
(1 row)

Another implementation with a FOREACH loop:

=> CREATE OR REPLACE FUNCTION map(a float[], func text) RETURNS float[]
AS $$
DECLARE
    x float;
    b float[]; -- empty array
BEGIN
    FOREACH x IN ARRAY a LOOP
        EXECUTE format('SELECT %I($1)',func) USING x INTO x;
        b := b || x;
    END LOOP;
    RETURN b;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT map(ARRAY[4.0,9.0,16.0],'sqrt');
   map
---------
 {2,3,4}
(1 row)
=> SELECT map(ARRAY[]::float[],'sqrt');
 map
-----
(1 row)

Page 21: 2. The reduce Function

=> CREATE FUNCTION reduce(a float[], func text) RETURNS float
AS $$
DECLARE
    i integer;
    r float := NULL;
BEGIN
    IF cardinality(a) > 0 THEN
        r := a[array_lower(a,1)];
        FOR i IN array_lower(a,1)+1 .. array_upper(a,1) LOOP
            EXECUTE format('SELECT %I($1,$2)',func) USING r, a[i]
                INTO r;
        END LOOP;
    END IF;
    RETURN r;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

greatest (as well as least) is not a function but a built-in conditional expression, and cannot be used directly due to escaping:

=> SELECT reduce( ARRAY[1.0,3.0,2.0], 'greatest');
ERROR:  function greatest(double precision, double precision) does not exist
LINE 1: SELECT "greatest"($1,$2)
               ^
HINT:  No function matches the given name and argument types. You might need to add
explicit type casts.
QUERY:  SELECT "greatest"($1,$2)
CONTEXT:  PL/pgSQL function reduce(double precision[],text) line 9 at EXECUTE

Instead, use the maximum function implemented in the demo.

=> CREATE FUNCTION maximum(VARIADIC a anycompatiblearray, maxsofar OUT anycompatible)
AS $$
DECLARE
    x maxsofar%TYPE;
BEGIN
    FOREACH x IN ARRAY a LOOP
        IF x IS NOT NULL AND (maxsofar IS NULL OR x > maxsofar) THEN
            maxsofar := x;
        END IF;
    END LOOP;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT reduce(ARRAY[1.0,3.0,2.0], 'maximum');
 reduce
--------
      3
(1 row)
=> SELECT reduce(ARRAY[1.0], 'maximum');
 reduce
--------
      1
(1 row)
=> SELECT reduce(ARRAY[]::float[], 'maximum');
 reduce
--------
(1 row)

The FOREACH option:

Page 22: Page 22

=> CREATE OR REPLACE FUNCTION reduce(a float[], func text) RETURNS float
AS $$
DECLARE
    x float;
    r float;
    first boolean := true;
BEGIN
    FOREACH x IN ARRAY a LOOP
        IF first THEN
            r := x;
            first := false;
        ELSE
            EXECUTE format('SELECT %I($1,$2)',func) USING r, x INTO r;
        END IF;
    END LOOP;
    RETURN r;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT reduce(ARRAY[1.0,3.0,2.0], 'maximum');
 reduce
--------
      3
(1 row)
=> SELECT reduce(ARRAY[1.0], 'maximum');
 reduce
--------
      1
(1 row)
=> SELECT reduce(ARRAY[]::float[], 'maximum');
 reduce
--------
(1 row)

3. Polymorphic Function Variants

The map function.

=> DROP FUNCTION map(float[],text);
DROP FUNCTION
=> CREATE FUNCTION map(
    a anyarray,
    func text,
    elem anyelement DEFAULT NULL
)
RETURNS anyarray
AS $$
DECLARE
    x elem%TYPE;
    b a%TYPE;
BEGIN
    FOREACH x IN ARRAY a LOOP
        EXECUTE format('SELECT %I($1)',func) USING x INTO x;
        b := b || x;
    END LOOP;
    RETURN b;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

A dummy parameter of the anyelement type is required to declare a variable of the same type inside the function.

=> SELECT map(ARRAY[4.0,9.0,16.0],'sqrt');

Page 23: Page 23

                           map
---------------------------------------------------------
 {2.000000000000000,3.000000000000000,4.000000000000000}
(1 row)
=> SELECT map(ARRAY[]::float[],'sqrt');
 map
-----
(1 row)

Example with a different data type:

=> SELECT map(ARRAY[' a ','  b','c  '],'btrim');
   map
---------
 {a,b,c}
(1 row)

The reduce function.

=> DROP FUNCTION reduce(float[],text);
DROP FUNCTION
=> CREATE FUNCTION reduce(
    a anyarray,
    func text,
    elem anyelement DEFAULT NULL
)
RETURNS anyelement
AS $$
DECLARE
    x elem%TYPE;
    r elem%TYPE;
    first boolean := true;
BEGIN
    FOREACH x IN ARRAY a LOOP
        IF first THEN
            r := x;
            first := false;
        ELSE
            EXECUTE format('SELECT %I($1,$2)',func) USING r, x INTO r;
        END IF;
    END LOOP;
    RETURN r;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE FUNCTION add(x anyelement, y anyelement) RETURNS anyelement
AS $$
BEGIN
    RETURN x + y;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT reduce(ARRAY[1,-2,4], 'add');
 reduce
--------
      3
(1 row)
=> SELECT reduce(ARRAY['a','b','c'], 'concat');
 reduce
--------
 abc
(1 row)
=> \c postgres

Page 24: Page 24

You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_arrays;
DROP DATABASE

16 Lesson 16: PL/pgSQL / Error Handling

Native Markdown source page: PL/pgSQL / Error Handling
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_16_plpgsql_exceptions.html Archive: DEV1-handouts-16-en.zip PDF pages: 27

Page 1: PL/pgSQL / Error Handling

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Error Handling in PL/pgSQL Blocks
  • Error Names and Codes
  • Choosing an Error Handler
  • Error Handling Overhead

Page 3: Error Handling in a Block

Error handling is performed if there is an EXCEPTION section

  • Changes roll back to the savepoint at the beginning of the block
    • an implicit savepoint is set if the block contains an EXCEPTION section
  • If there is a handler that matches the error
    • error handler commands are executed
    • the block completes successfully
  • If there is no suitable handler
    • the block completes with an error

Speaker notes

If a run-time error occurs within a block, the program (block, function) is usually aborted, and the current transaction enters the failure mode: it cannot be committed and can only be rolled back.

But an error can be caught and processed. It can be done by extending the block with an additional EXCEPTION section, which lists error conditions and provides statements to handle each of them.

In general, EXCEPTION is similar to the try-catch construct available in some programming languages (except for specifics related to transactions, or course).

A savepoint is implicitly set at the start of every block containing an EXCEPTION section. Before an error is processed, all changes are rolled back to the savepoint and all locks are removed.

Because of the savepoint, COMMIT and ROLLBACK commands cannot be used in procedures with EXCEPTION. But although SAVEPOINT and ROLLBACK TO SAVEPOINT commands are not supported by PL/pgSQL, you can still use savepoints and rollbacks to savepoints both in functions and procedures implicitly.

https://postgrespro.com/docs/postgresql/16/plpgsql-control-structures#PLP GSQL-ERROR-TRAPPING

Page 4: Handling Errors in a Block

Let’s take a look at a simple example.

=> CREATE TABLE t(id integer);
CREATE TABLE
=> INSERT INTO t(id) VALUES (1);
INSERT 0 1

If there are no errors, all statements in a block are executed as usual:

=> DO $$
DECLARE
    n integer;
BEGIN
    SELECT id INTO STRICT n FROM t;
    RAISE NOTICE 'The SELECT INTO statement has completed, n = %', n;
END
$$;
NOTICE:  The SELECT INTO statement has completed, n = 1
DO

Now let’s insert a “redundant” row to trigger an error.

=> INSERT INTO t(id) VALUES (2);
INSERT 0 1

If there is no EXCEPTION section in a block, the statement execution is interrupted, and the whole block is considered to be completed with an error condition:

=> DO $$
DECLARE
    n integer;
BEGIN
    SELECT id INTO STRICT n FROM t;
    RAISE NOTICE 'The SELECT INTO statement has completed, n = %', n;
END
$$;
ERROR:  query returned more than one row
HINT:  Make sure the query returns a single row, or use LIMIT 1.
CONTEXT:  PL/pgSQL function inline_code_block line 5 at SQL statement

To catch an error, a block must have an EXCEPTION section, which defines one or more error handlers. This construct works similar to CASE: conditions are scanned from top to bottom, the first suitable code path is selected, and its statements are executed. What will be displayed?

=> DO $$
DECLARE
    n integer;
BEGIN
    n := 3;
    INSERT INTO t(id) VALUES (n);
    SELECT id INTO STRICT n FROM t;
    RAISE NOTICE 'The SELECT INTO statement has completed, n = %', n;
EXCEPTION
    WHEN no_data_found THEN
        RAISE NOTICE 'No data';
    WHEN too_many_rows THEN
        RAISE NOTICE 'Too much data';
        RAISE NOTICE 'Rows in a table: %, n = %', (SELECT count(*) FROM t), n;
END
$$;
NOTICE:  Too much data
NOTICE:  Rows in a table: 2, n = 3
DO

The executed handler corresponds to the too_many_rows error. Note: if a handler is executed, the table contains two rows because of a rollback to an implicit savepoint at the beginning of the block.

Page 5: Page 5

Also note that the local variable keeps the value that was there when the error occurred.

Note

Note the following subtlety: if an error occurs in the DECLARE section or within the EXCEPTION section of the handler itself, it will be impossible to catch it in this block.

=> DO $$
DECLARE
    n integer := 1 / 0; -- an error is not trapped here
BEGIN
    RAISE NOTICE 'Success';
EXCEPTION
    WHEN division_by_zero THEN
        RAISE NOTICE 'Division by zero';
END
$$;
ERROR:  division by zero
CONTEXT:  SQL expression "1 / 0"
PL/pgSQL function inline_code_block line 3 during statement block local variable
initialization

Page 6: Error Names and Codes

Source slide page 6: Error Names and Codes{#source-figure-006 caption=“Source slide page 6: Error Names and Codes”}

Error info

  • error name
  • five-character error code
  • additional info: a short message, a detailed message, a hint, names of objects
  • related to this error
  • Two-level hierarchy

Speaker notes

Each possible error has a name and a code (a five-character string). WHEN clauses accept both error names and error codes.

All errors are classified into a two-level hierarchy of sorts. Each error class has a code that ends with three zeros; it corresponds to any error with the same first two characters in its code.

For example, the code 23000 defines the class that includes all errors dealing with violations of integrity constraints (such as 23502, which stands for NOT NULL constraint violation, or 23505, which indicates a UNIQUE constraint violation).

Thus, apart from regular errors, you can specify an error class by its name or code. Besides, you can use a special name OTHERS to catch any errors (except for the fatal ones).

Apart from the name and code, each error can provide additional debug information: a short error message, a detailed message, and a hint.

All errors are described in documentation in Appendix A:

https://postgrespro.com/docs/postgresql/16/errcodes-appendix

Errors can be not only trapped, but also raised programmatically.

https://postgrespro.com/docs/postgresql/16/plpgsql-errors-and-messages

Page 7: Error Names and Codes

We have already seen error names; error codes are specified using SQLSTATE. An error handler can return an error code and the corresponding message using the predefined variables SQLSTATE and SQLERRM (the variables are undefined outside of the EXCEPTION block).

=> DO $$
DECLARE
    n integer;
BEGIN
    SELECT id INTO STRICT n FROM t;
EXCEPTION
    WHEN SQLSTATE 'P0003' OR no_data_found THEN -- there can be several conditions
        RAISE NOTICE '%: %', SQLSTATE, SQLERRM;
END
$$;
NOTICE:  P0003: query returned more than one row
DO

Which error handler will be used?

=> DO $$
DECLARE
    n integer;
BEGIN
    SELECT id INTO STRICT n FROM t;
EXCEPTION
    WHEN no_data_found THEN
        RAISE NOTICE 'No data. %: %', SQLSTATE, SQLERRM;
    WHEN plpgsql_error THEN
        RAISE NOTICE 'Another error. %: %', SQLSTATE, SQLERRM;
    WHEN too_many_rows THEN
        RAISE NOTICE 'Too much data. %: %', SQLSTATE, SQLERRM;
END
$$;
NOTICE:  Another error. P0003: query returned more than one row
DO

The first applicable handler is selected, plpgsql_error in this case (remember: this is not a specific error, but an error category). We will never get to the last error handler. You can force an error using either its code or its name. Here we use a special name others, which corresponds to any error that can be trapped (except for assertion failures and cases when the execution is aborted by user — you can catch them separately, but you almost never need to).

=> DO $$
BEGIN
    RAISE no_data_found;
EXCEPTION
    WHEN others THEN
        RAISE NOTICE '%: %', SQLSTATE, SQLERRM;
END
$$;
NOTICE:  P0002: no_data_found
DO

If required, it is also possible to incorporate user-provided error codes that are not predefined, as well as pass some additional information (the example illustrates only some of the supported features):

=> DO $$
BEGIN
    RAISE SQLSTATE 'ERR01' USING
        message := 'Matrix failure',
        detail  := 'Irrecoverable matrix failure has occurred during execution',
        hint := 'Contact your system administrator';
END
$$;
ERROR:  Matrix failure
DETAIL:  Irrecoverable matrix failure has occurred during execution
HINT:  Contact your system administrator
CONTEXT:  PL/pgSQL function inline_code_block line 3 at RAISE

Page 8: Page 8

Error handlers cannot get this information from variables; there is a special construct for analyzing such data in the code:

=> DO $$
DECLARE
    message text;
    detail text;
    hint text;
BEGIN
    RAISE SQLSTATE 'ERR01' USING
        message := 'Matrix failure',
        detail  := 'Irrecoverable matrix failure has occurred during execution',
        hint := 'Contact your system administrator';
EXCEPTION
    WHEN others THEN
        GET STACKED DIAGNOSTICS
            message := MESSAGE_TEXT,
            detail := PG_EXCEPTION_DETAIL,
            hint := PG_EXCEPTION_HINT;
        RAISE NOTICE E'\nmessage = %\ndetail = %\nhint = %',
            message, detail, hint;
END
$$;
NOTICE:
message = Matrix failure
detail = Irrecoverable matrix failure has occurred during execution
hint = Contact your system administrator
DO

Page 9: Choosing a Handler

An unhandled error is sent one level up

  • into the enclosing PL/pgSQL block, if available
  • into the calling routine, if available
  • The search path of a handler is determined by the call stack
    • it is not defined statically, but depends on how the program executes
  • An unhandled error is passed to the client
    • the transaction enters the failure mode and has to be rolled back by the client
    • the error is registered in the server log

Speaker notes

If none of the conditions listed in the EXCEPTION section is triggered, the error goes one level up.

If an error has occurred in the inner block of a nested structure, the server will search for a handler in the enclosing block. If there is no suitable handler either, the whole outer block will be treated as failed, while the error will be passed to the next nesting level, and so on.

If the error goes through the whole nested structure and does not find an appropriate handler, it goes further up to the level of the routine that has called the outermost block. Therefore, you have to analyze the call stack to determine the order in which different error handlers will be applied.

If none of the available error handlers is triggered:

● The error message usually gets into the server log (the exact behavior depends on the server settings; see PL/pgSQL. Debugging lesson).

● The error is reported to the client that has initiated this operation in the database. The client cannot do anything about the cause of the error at this point: the transaction enters the failure mode, and it can only be rolled back.

It is up to the client to choose how to handle the error from there. For example, psql will display the error message and all the debugging information available. An end-user client may display a generic message like “contact your system administrator”.

Page 10: Choosing a Handler

Let’s take a look at several examples of choosing a handler in nested blocks. What will be displayed?

=> DO $$
BEGIN
    BEGIN
        SELECT 1/0;
        RAISE NOTICE 'The inner block has completed';
    EXCEPTION
        WHEN division_by_zero THEN
            RAISE NOTICE 'Error in the inner block';
    END;
    RAISE NOTICE 'The outer block has completed';
EXCEPTION
    WHEN division_by_zero THEN
        RAISE NOTICE 'Error in the outer block';
END
$$;
NOTICE:  Error in the inner block
NOTICE:  The outer block has completed
DO

An error is handled in the same block where it has occurred. The outer block is executed as if there has been no error at all. And now?

=> DO $$
BEGIN
    BEGIN
        SELECT 1/0;
        RAISE NOTICE 'The inner block has completed';
    EXCEPTION
        WHEN no_data_found THEN
            RAISE NOTICE 'Error in the inner block';
    END;
    RAISE NOTICE 'The outer block has completed';
EXCEPTION
    WHEN division_by_zero THEN
        RAISE NOTICE 'Error in the outer block';
END
$$;
NOTICE:  Error in the outer block
DO

The handler in the inner block is not applicable; the block completes with an error that is handled in the outer block. Remember that the block containing an EXCEPTION section is rolled back to the implicit savepoint at the beginning of this block. In this case, all changes made in both blocks will be rolled back. And now?

=> DO $$
BEGIN
    BEGIN
        SELECT 1/0;
        RAISE NOTICE 'The inner block has completed';
    EXCEPTION
        WHEN no_data_found THEN
            RAISE NOTICE 'Error in the inner block';
    END;
    RAISE NOTICE 'The outer block has completed';
EXCEPTION
    WHEN no_data_found THEN
        RAISE NOTICE 'Error in the outer block';
END
$$;
ERROR:  division by zero
CONTEXT:  SQL statement "SELECT 1/0"
PL/pgSQL function inline_code_block line 4 at SQL statement

None of the handlers is triggered, and the whole transaction is aborted.

Page 11: Page 11

There is usually no need to handle all possible errors in the server code. There is nothing wrong in passing an error to the client. In general, an error should be handled at the level where something meaningful can be done about it. So it makes sense to process an error within the database if it can be addressed on the server side (e.g., the operation can be repeated in case of a serialization failure). We’ll talk about logging error messages in PL/pgSQL. Debugging lesson. Now let’s take a look at an example that uses procedures.

=> CREATE PROCEDURE foo()
AS $$
BEGIN
     CALL bar();
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CREATE PROCEDURE bar()
AS $$
BEGIN
    CALL baz();
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CREATE PROCEDURE baz()
AS $$
BEGIN
    PERFORM 1 / 0;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

What will happen if we call this procedure?

=> CALL foo();
ERROR:  division by zero
CONTEXT:  SQL statement "SELECT 1 / 0"
PL/pgSQL function baz() line 3 at PERFORM
SQL statement "CALL baz()"
PL/pgSQL function bar() line 3 at CALL
SQL statement "CALL bar()"
PL/pgSQL function foo() line 3 at CALL

The error message displays the call stack: top to bottom means inside out.

Note

Note Note that this message (like many others) uses the term “function” instead of “procedure”. An error handler can also provide access to the call stack, but it will be presented as a single string:

=> CREATE OR REPLACE PROCEDURE bar()
AS $$
DECLARE
    msg text;
    ctx text;
BEGIN
    CALL baz();
EXCEPTION
    WHEN others THEN
        GET STACKED DIAGNOSTICS
             msg := MESSAGE_TEXT,
             ctx := PG_EXCEPTION_CONTEXT;
        RAISE NOTICE E'\nError: %\nError stack:\n%\n', msg, ctx;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Let’s check the result:

=> CALL foo();

Page 12: Page 12

NOTICE:
Error: division by zero
Error stack:
SQL statement "SELECT 1 / 0"
PL/pgSQL function baz() line 3 at PERFORM
SQL statement "CALL baz()"
PL/pgSQL function bar() line 6 at CALL
SQL statement "CALL bar()"
PL/pgSQL function foo() line 3 at CALL
CALL

Since a block with an EXCEPTION section creates an implicit savepoint, procedures cannot use COMMIT and ROLLBACK commands both in this block and in all the blocks up the call stack.

=> CREATE OR REPLACE PROCEDURE baz()
AS $$
BEGIN
    COMMIT;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CALL foo();
NOTICE:
Error: invalid transaction termination
Error stack:
PL/pgSQL function baz() line 3 at COMMIT
SQL statement "CALL baz()"
PL/pgSQL function bar() line 6 at CALL
SQL statement "CALL bar()"
PL/pgSQL function foo() line 3 at CALL
CALL

Page 13: Overhead

Any block with an EXCEPTION section is executed slower

  • because of setting an implicit savepoint
  • Additional costs are incurred in case of an error
    • because of the rollback to the savepoint
  • Error handling can and should be used, but not overused
    • PL/pgSQL is an interpreted language that uses SQL to compute expressions
    • anyway
    • the speed is more than enough for most tasks
    • performance issues are usually related to queries, not to PL/pgSQL code

Speaker notes

The mere inclusion of an EXCEPTION section already incurs overhead because it requires setting an implicit savepoint at the beginning of the block. If an error occurs, the rollback to the savepoint increases the overhead even more.

So if there is a simple way to avoid exception handling, it’s better to do without it; you should not base your application logic on “exception juggling”.

However, if error handing is really required, you should use it without doubt: errors can and must be handled regardless of the overhead.

First, the PL/pgSQL language itself is quite slow because of interpreting instructions and constantly calling SQL to compute expressions.

Second, its speed is usually still adequate. Yes, you can create a faster implementation in C, but what’s the point?

And third, the main performance issues are usually caused by bad query plans that affect query speed, not by the execution speed of procedural code (for details, see the QPT course that deals with query performance tuning).

But if there is an alternative that is both simpler and faster, it should certainly be preferred.

Page 14: Overhead

To estimate the overhead, let’s take a look at the following simple example. Suppose we have a table with a text field that stores arbitrary data inserted by users (although usually a sign of bad design, it may sometimes be required). We need to extract all numbers into a separate column of a numeric type.

=> CREATE TABLE data(comment text, n integer);
CREATE TABLE
=> INSERT INTO data(comment)
SELECT CASE
        WHEN random() < 0.01 THEN 'not a number' --  1%
        ELSE (random()*1000)::integer::text      -- 99%
    END
FROM generate_series(1,1_000_000);
INSERT 0 1000000

Let’s solve this problem by handling errors that come up when converting text to integer:

=> CREATE FUNCTION safe_to_integer_ex(s text) RETURNS integer
AS $$
BEGIN
    RETURN s::integer;
EXCEPTION
    WHEN invalid_text_representation THEN
        RETURN NULL;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s check the result:

=> \timing on
Timing is on.
=> UPDATE data SET n = safe_to_integer_ex(comment);
UPDATE 1000000
Time: 13175.397 ms (00:13.175)
=> \timing off
Timing is off.
=> SELECT count(*) FROM data WHERE n IS NOT NULL;
 count
--------
 990104
(1 row)

The following implementation of our function will check the format using a (slightly simplified) regular expression, without error handling:

=> CREATE FUNCTION safe_to_integer_re(s text) RETURNS integer
AS $$
BEGIN
    RETURN CASE
        WHEN s ~ '^\d+$' THEN s::integer
        ELSE NULL
    END;
END
$$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s check this implementation:

=> \timing on
Timing is on.
=> UPDATE data SET n = safe_to_integer_re(comment);

Page 15: Page 15

UPDATE 1000000
Time: 8843.259 ms (00:08.843)
=> \timing off
Timing is off.
=> SELECT count(*) FROM data WHERE n IS NOT NULL;
 count
--------
 990104
(1 row)

This implementation is significantly faster. In this example, the exception has occurred in 1% of cases only. The more often it occurs, the more overhead will be incurred by rollbacks to the savepoint.

=> UPDATE data SET comment = 'not a number'; -- 100%
UPDATE 1000000
=> \timing on
Timing is on.
=> UPDATE data SET n = safe_to_integer_ex(comment);
UPDATE 1000000
Time: 19428.566 ms (00:19.429)
=> \timing off
Timing is off.

In some cases (which are not infrequent), you can do without error handling if you choose other suitable means. Problem: return a row from a table or NULL, if there is no such row.

=> CREATE TABLE categories(code text UNIQUE, description text);
CREATE TABLE
=> INSERT INTO categories VALUES ('books','Books'), ('discs','CDs');
INSERT 0 2

A function with error handling:

=> CREATE FUNCTION get_cat_desc(code text) RETURNS text
AS $$
DECLARE
    desc text;
BEGIN
    SELECT c.description INTO STRICT desc
    FROM categories c
    WHERE c.code = get_cat_desc.code;
    RETURN desc;
EXCEPTION
    WHEN no_data_found THEN
        RETURN NULL;
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s check that the function works as expected:

=> SELECT get_cat_desc('books');
 get_cat_desc
--------------
 Books
(1 row)
=> SELECT get_cat_desc('movies');
 get_cat_desc
--------------
(1 row)

Page 16: Page 16

Can we make it simpler? Yes, we just need to remove STRICT or use a subquery:

=> CREATE OR REPLACE FUNCTION get_cat_desc(code text) RETURNS text
AS $$
BEGIN
    RETURN (SELECT c.description
            FROM categories c
            WHERE c.code = get_cat_desc.code);
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

It’s a good illustration that we do not need PL/pgSQL here at all: we can simply use SQL. Let’s check the result:

=> SELECT get_cat_desc('books');
 get_cat_desc
--------------
 Books
(1 row)
=> SELECT get_cat_desc('movies');
 get_cat_desc
--------------
(1 row)

Problem: update a table row with the specified ID; if there is no such row, insert it. Here is the first approach. What is wrong with it?

=> CREATE OR REPLACE FUNCTION change(code text, description text)
RETURNS void
AS $$
DECLARE
    cnt integer;
BEGIN
    SELECT count(*) INTO cnt
    FROM categories c WHERE c.code = change.code;
    IF cnt = 0 THEN
        INSERT INTO categories VALUES (code, description);
    ELSE
        UPDATE categories c
        SET description = change.description
        WHERE c.code = change.code;
    END IF;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Almost everything is bad here, starting from the fact that such a function will not work correctly at the Read Committed isolation level if there are several concurrent sessions. That’s because the data in the database can change between the executed SELECT statement and the next operation. It can be easily demonstrated by introducing a delay between the commands. For variety, let’s take a slightly different (but also incorrect) option:

Page 17: Page 17

=> CREATE OR REPLACE FUNCTION change(code text, description text)
RETURNS void
AS $$
DECLARE
    cnt integer;
BEGIN
    SELECT count(*) INTO cnt
    FROM categories c WHERE c.code = change.code;
    PERFORM pg_sleep(1); -- anything can happen here
    IF cnt = 0 THEN
        INSERT INTO categories VALUES (code, description);
    ELSE
        UPDATE categories c
        SET description = change.description
        WHERE c.code = change.code;
    END IF;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Now let’s run this function in two different sessions, almost simultaneously:

=> SELECT change('games', 'Games');
=> SELECT change('games', 'Games');
ERROR:  duplicate key value violates unique constraint "categories_code_key"
DETAIL:  Key (code)=(games) already exists.
CONTEXT:  SQL statement "INSERT INTO categories VALUES (code, description)"
PL/pgSQL function change(text,text) line 11 at SQL statement
 change
--------
(1 row)

A correct solution can be implemented using error handling:

=> CREATE OR REPLACE FUNCTION change(code text, description text)
RETURNS void
AS $$
BEGIN
    LOOP
        UPDATE categories c
        SET description = change.description
        WHERE c.code = change.code;
        EXIT WHEN FOUND;
        PERFORM pg_sleep(1); -- for the demo
        BEGIN
            INSERT INTO categories VALUES (code, description);
            EXIT;
        EXCEPTION
            WHEN unique_violation THEN NULL;
        END;
    END LOOP;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s check the result.

=> SELECT change('vynil', 'Vynil records');
=> SELECT change('vynil', 'Vynil records');
 change
--------
(1 row)

Page 18: Page 18

 change
--------
(1 row)

Yes, now everything is correct. But there is an easier way: you can use a special flavor of the INSERT command that attempts to insert a row and performs an update if a conflict occurs. Again, all you need is pure SQL.

=> CREATE OR REPLACE FUNCTION change(code text, description text)
RETURNS void
VOLATILE LANGUAGE sql
BEGIN ATOMIC
    INSERT INTO categories VALUES (code, description)
    ON CONFLICT(code)
        DO UPDATE SET description = change.description;
END;
CREATE FUNCTION

Problem: make sure that the data is processed by one transaction at a time (at the Read Committed isolation level). Using the same table, let’s assume that the category sometimes requires a special single-threaded processing. The function can be declared as follows:

=> CREATE OR REPLACE FUNCTION process_cat(code text) RETURNS text
AS $$
BEGIN
    PERFORM c.code FROM categories c WHERE c.code = process_cat.code
        FOR UPDATE NOWAIT; -- try to lock the row immediately
    PERFORM pg_sleep(1); -- the processing itself
    RETURN 'The category has been processed';
EXCEPTION
    WHEN lock_not_available THEN
        RETURN 'Another process is already processing this category';
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s check that everything is correct:

=> SELECT process_cat('books');
=> SELECT process_cat('books');
                     process_cat
-----------------------------------------------------
 Another process is already processing this category
(1 row)
           process_cat
---------------------------------
 The category has been processed
(1 row)

But this problem can also be solved without error handling if we use advisory locks:

=> CREATE OR REPLACE FUNCTION process_cat(code text) RETURNS text
AS $$
BEGIN
    IF pg_try_advisory_xact_lock(hashtext(code)) THEN
        PERFORM pg_sleep(1); -- the processing itself
        RETURN 'The category has been processed';
    ELSE
        RETURN 'Another transaction is already processing this category';
    END IF;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Let’s check the result:

=> SELECT process_cat('books');

Page 19: Page 19

=> SELECT process_cat('books');
                       process_cat
---------------------------------------------------------
 Another transaction is already processing this category
(1 row)
           process_cat
---------------------------------
 The category has been processed
(1 row)

Let’s see an example where we cannot do without error handling. Problem: process a set of documents; a processing error of a particular document should not result in a general failure.

=> CREATE TYPE doc_status AS ENUM -- enumeration type
    ('READY', 'ERROR', 'PROCESSED');
CREATE TYPE
=> CREATE TABLE documents(
    id integer,
    version integer,
    status doc_status,
    message text
);
CREATE TABLE
=> INSERT INTO documents(id, version, status)
SELECT id, 1, 'READY' FROM generate_series(1,100) id;
INSERT 0 100

A procedure that processes a single document can sometimes result in an error:

=> CREATE PROCEDURE process_one_doc(id integer)
AS $$
BEGIN
    UPDATE documents d
    SET version = version + 1
    WHERE d.id = process_one_doc.id;
    -- processing can take a while
    IF random() < 0.05 THEN
        RAISE EXCEPTION 'Catastrophic failure';
    END IF;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Now let’s create a procedure that processes all documents. It loops through the documents to process them one by one and catches an error if required.

Note

Note Note that transactions are committed outside of the block that contains the EXCEPTION section.

Page 20: Page 20

=> CREATE PROCEDURE process_docs()
AS $$
DECLARE
    doc record;
BEGIN
    FOR doc IN (SELECT id FROM documents WHERE status = 'READY')
    LOOP
        BEGIN
            CALL process_one_doc(doc.id);
            UPDATE documents d
            SET status = 'PROCESSED'
            WHERE d.id = doc.id;
        EXCEPTION
            WHEN others THEN
                UPDATE documents d
                SET status = 'ERROR', message = sqlerrm
                WHERE d.id = doc.id;
        END;
        COMMIT; -- there is a separate transaction for each document
    END LOOP;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

You can set up a similar processing using a function, but then all documents will be handled within a single common transaction, which can be a problem if processing takes a long time. This question is discussed at length in the DEV2 course. Let’s check the result:

=> CALL process_docs();
CALL
=> SELECT d.status, d.version, count(*)::integer
FROM documents d
GROUP BY d.status, d.version;
  status   | version | count
-----------+---------+-------
 ERROR     |       1 |     3
 PROCESSED |       2 |    97
(2 rows)

As you can see, some of the documents have not been processed, but it has not affected the processing of the others. It is convenient that the information about the occurred errors is stored in the table itself:

=> SELECT * FROM documents d WHERE d.status = 'ERROR';
 id | version | status |       message
----+---------+--------+----------------------
 18 |       1 | ERROR  | Catastrophic failure
 47 |       1 | ERROR  | Catastrophic failure
 53 |       1 | ERROR  | Catastrophic failure
(3 rows)

Please note once again that if an error occurs, the changes are rolled back to the savepoint at the beginning of the block; that’s why documents with the ERROR status still have version 1.

Page 21: Takeaways

The search for an error handler is performed “inside out” according to block nesting and routine call stack An implicit savepoint is set at the beginning of the block that contains EXCEPTION; if an error occurs, a rollback to this savepoint is performed

  • An unhandled error aborts the transaction; the error message is passed to the client and registered in the server log
  • Error handling incurs overhead

Page 22: Practice

  1. An attempt to put in the same author several times when adding a book causes an error. Modify the add_book function: catch the unique constraint violation error and produce an error with a meaningful message instead.
  • Try it out in the application.

Speaker notes

  1. To determine the name of the error that has to be caught, catch all errors (WHEN OTHERS) and display the required information (by raising another error with the corresponding text).

Then remember to replace WITH OTHERS with a specific error: let all other error types be handled at a higher level if there is no opportunity to do anything useful in this particular position in the code.

(In a real environment, unique constraint violations should not be handled either: it is better to forbid entering the same author twice at the application level.)

Page 23: 1. Handling Duplicate Author Names When Adding Books

=> CREATE OR REPLACE FUNCTION add_book(title text, authors integer[])
RETURNS integer
AS $$
DECLARE
    book_id integer;
    id integer;
    seq_num integer := 1;
BEGIN
    INSERT INTO books(title)
        VALUES(title)
        RETURNING books.book_id INTO book_id;
    FOREACH id IN ARRAY authors LOOP
        INSERT INTO authorship(book_id, author_id, seq_num)
            VALUES (book_id, id, seq_num);
        seq_num := seq_num + 1;
    END LOOP;
    RETURN book_id;
EXCEPTION
    WHEN unique_violation THEN
        RAISE EXCEPTION 'The same author cannot be specified twice';
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

Page 24: Practice+

  1. Some languages use the construct try … catch … finally …, where try corresponds to BEGIN, catch corresponds to EXCEPTION, and the statements located in the finally block are always triggered regardless of whether an exception has occurred and whether it has been processed by the catch block. Find a way to achieve a similar behavior in PL/pgSQL. 2. Compare the call stacks returned by GET STACKED DIAGNOSTICS with pg_exception_context and GET [CURRENT] DIAGNOSTICS with pg_context. 3. Create a function getstack that returns the current call stack as a string array. The getstack function itself must not be a part of the stack.

Speaker notes

  1. The easiest way to do it is to simply repeat the finally statements in several places. But you should try to come up with a solution that avoids code duplication.

  2. First, refer to the documentation:

https://postgrespro.com/docs/postgresql/16/plpgsql-statements#PLPGSQL-STATEMENTS-DIAGNOSTICS

https://postgrespro.com/docs/postgresql/16/plpgsql-control-structures#PLPG SQL-EXCEPTION-DIAGNOSTICS

Page 25: 1. Try-catch-finally

=> CREATE DATABASE plpgsql_exceptions;
CREATE DATABASE
=> \c plpgsql_exceptions
You are now connected to database "plpgsql_exceptions" as user "student".

The tricky part is the ’finally’ statements. They must always execute, even if there is an exception catch (in the EXCEPTION block). The solution can use two nested blocks and a dummy exception that is thrown when the inner block is executed as expected. This makes it possible to put finally statements in one place: the external block error handler.

=> DO $$
BEGIN
    BEGIN
        RAISE NOTICE 'try statements';
        --
        RAISE NOTICE '...no exception';
    EXCEPTION
        WHEN no_data_found THEN
            RAISE NOTICE 'catch statements';
    END;
    RAISE SQLSTATE 'ALLOK';
EXCEPTION
    WHEN others THEN
        RAISE NOTICE 'finally statements';
        IF SQLSTATE != 'ALLOK' THEN
            RAISE;
        END IF;
END
$$;
NOTICE:  try statements
NOTICE:  ...no exception
NOTICE:  finally statements
DO
=> DO $$
BEGIN
    BEGIN
        RAISE NOTICE 'try statements';
        --
        RAISE NOTICE '...exception that is processed';
        RAISE no_data_found;
    EXCEPTION
        WHEN no_data_found THEN
            RAISE NOTICE 'catch statements';
    END;
    RAISE SQLSTATE 'ALLOK';
EXCEPTION
    WHEN others THEN
        RAISE NOTICE 'finally statements';
        IF SQLSTATE != 'ALLOK' THEN
            RAISE;
        END IF;
END
$$;
NOTICE:  try statements
NOTICE:  ...exception that is processed
NOTICE:  catch statements
NOTICE:  finally statements
DO

Page 26: Page 26

=> DO $$
BEGIN
    BEGIN
        RAISE NOTICE 'try statements';
        --
        RAISE NOTICE '...exception not handled';
        RAISE division_by_zero;
    EXCEPTION
        WHEN no_data_found THEN
            RAISE NOTICE 'catch statements';
    END;
    RAISE SQLSTATE 'ALLOK';
EXCEPTION
    WHEN others THEN
        RAISE NOTICE 'finally statements';
        IF SQLSTATE != 'ALLOK' THEN
            RAISE;
        END IF;
END
$$;
NOTICE:  try statements
NOTICE:  ...exception not handled
NOTICE:  finally statements
ERROR:  division_by_zero
CONTEXT:  PL/pgSQL function inline_code_block line 7 at RAISE

But in the proposed solution, all changes made in the block are always rolled back, so it is not suitable for commands that change the database state. Also, don’t forget about the exception handling overhead. This task is just an exercise.

2. GET DIAGNOSTICS

=> DO $$
DECLARE
    ctx text;
BEGIN
    RAISE division_by_zero;                       -- line 5
EXCEPTION
    WHEN others THEN
        GET STACKED DIAGNOSTICS ctx := PG_EXCEPTION_CONTEXT;
        RAISE NOTICE E'stacked =\n%', ctx;
        GET CURRENT DIAGNOSTICS ctx := PG_CONTEXT; -- line 10
        RAISE NOTICE E'current =\n%', ctx;
END
$$;
NOTICE:  stacked =
PL/pgSQL function inline_code_block line 5 at RAISE
NOTICE:  current =
PL/pgSQL function inline_code_block line 10 at GET DIAGNOSTICS
DO

GET STACKED DIAGNOSTICS provides the stack of calls that resulted in the error. GET CURRENT DIAGNOSTICS provides the current stack of calls.

3. Call Stack as an Array

The function itself:

=> CREATE FUNCTION getstack() RETURNS text[]
AS $$
DECLARE
    ctx text;
BEGIN
    GET DIAGNOSTICS ctx := PG_CONTEXT;
    RETURN (regexp_split_to_array(ctx, E'\n'))[2:];
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

To test it, create several functions that call each other:

=> CREATE FUNCTION foo() RETURNS integer
AS $$
BEGIN
    RETURN bar();
END
$$ VOLATILE LANGUAGE plpgsql;

Page 27: Page 27

CREATE FUNCTION
=> CREATE FUNCTION bar() RETURNS integer
AS $$
BEGIN
    RETURN baz();
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE FUNCTION baz() RETURNS integer
AS $$
BEGIN
    RAISE NOTICE '%', getstack();
    RETURN 0;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT foo();
NOTICE:  {"PL/pgSQL function baz() line 3 at RAISE","PL/pgSQL function bar() line 3 at
RETURN","PL/pgSQL function foo() line 3 at RETURN"}
 foo
-----
   0
(1 row)
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_exceptions;
DROP DATABASE

17 Lesson 17: PL/pgSQL / Triggers

Native Markdown source page: PL/pgSQL / Triggers
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_17_plpgsql_triggers.html Archive: DEV1-handouts-16-en.zip PDF pages: 28

Page 1: PL/pgSQL / Triggers

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Triggers and Trigger Functions
  • Triggering
  • Execution Context of a Trigger Function
  • Return Values
  • Do’s and Don’ts
  • Event Triggers

Page 3: Triggers and Functions

Trigger

  • a database object: a list of events to process
  • once an event occurs, the trigger function is called,
  • and the call context is passed to the function
  • Trigger function
    • a database object: event processing code
    • executed in the same transaction as the main operation
    • convention: the function does not take any parameters,
    • returns a value of the trigger pseudotype (which is virtually record)
    • can be reused with multiple triggers

Speaker notes

Triggers are used to set off particular actions in response to particular events. A trigger consists of two parts: the trigger itself (which defines the events) and a trigger function (which defines the actions). Both the trigger and the function are independent database objects.

When an event occurs which the trigger is waiting for, the trigger function is called. It receives the context of the call, which defines the exact trigger that has called the function and the exact conditions that have led to this call.

A trigger function is a regular function that follows some conventions:

● It can be written in any language except pure SQL.

● It must have no parameters.

● Its return value is of the trigger type (which is actually a pseudotype; a record corresponding to a table row is returned instead).

The trigger function is executed in the same transaction as the main operation. Thus, if a trigger function results in an error, the whole transaction is aborted.

https://postgrespro.com/docs/postgresql/16/trigger-definition

Page 4: Events

INSERT, UPDATE, DELETE

  • tables
  • before/after statement
  • before/after row
  • views
  • before/after statement
  • instead of
  • row
  • TRUNCATE
    • tables
    • before/after statement
  • WHEN condition
    • sets an additional filter

Speaker notes

Triggers can fire for INSERT, UPDATE, or DELETE operations performed on tables or views, as well as for the TRUNCATE operation on tables.

A trigger can fire before the specified action (BEFORE), after the action (AFTER), or instead of the action (INSTEAD OF).

A trigger can fire once for the whole operation (FOR EACH STATEMENT) or for each affected row (FOR EACH ROW).

There are some combinations of these conditions that are not supported. For example, INSTEAD OF triggers can be defined only for views at the row level, while TRUNCATE triggers can be defined only for tables and only at the statement level. Possible combinations are listed on this slide.

Besides, you can limit the scope controlled by the trigger by specifying the WHEN condition: if this condition is not true, the trigger does not fire.

https://postgrespro.com/docs/postgresql/16/sql-createtrigger

Page 5: Before Statement

Triggers

  • BEFORE STATEMENT
  • before the operation
  • Return value
    • operation execution
    • is ignored
  • Context
    • TG variables

Speaker notes

Let’s take a closer look at different trigger types.

The BEFORE STATEMENT trigger fires only once per operation, regardless of the number of affected rows (even if there are none). It happens before the start of the operation.

The return value of the trigger function is ignored. If there is an error in the trigger, the operation is canceled. Since the trigger function has no parameters, the call context in PL/pgSQL is passed via predefined TG variables, such as:

● TG_WHEN = BEFORE

● TG_LEVEL = STATEMENT

● TG_OP = INSERT/UPDATE/DELETE/TRUNCATE

etc. You can also pass a user-defined context (which is analogous to the absent parameters) via the TG_ARGV variable, although it is usually advisable to create several specialized functions instead of a single generic one.

https://postgrespro.com/docs/postgresql/16/plpgsql-trigger

Page 6: Before Row

Triggers

  • BEFORE STATEMENT
  • before the action on the row is taken
  • during the statement execution
  • operation execution
  • Return value
    • BEFORE ROW
    • a row (possibly modified)
    • null cancels the action
  • Context
    • OLD
    • update, delete
    • NEW
    • insert, update
    • TG variables

Speaker notes

BEFORE ROW triggers fire each time an operation is about to process a row. It happens right during the operation execution.

Trigger functions get the context via variables, such as:

● OLD — an old state of the row (undefined for insertion)

● NEW — an updated state of the row (undefined for deletion)

● TG_WHEN = BEFORE

● TG_LEVEL = ROW

● TG_OP = INSERT/UPDATE/DELETE

etc.

The NULL return value is interpreted as cancellation of the action for the current row. The execution of the operation itself will continue, but the current row won’t be processed, and other triggers won’t fire for this row.

To avoid interfering with the operation, the trigger must return the received row without any modifications, so it must always return NEW for insert and update operations. For delete operations, it can return any value except NULL (usually OLD is used).

But the trigger function can also change the NEW value to affect the result of the operation; this trigger is often defined exactly for this purpose.

Page 7: Instead of Row

Triggers

  • BEFORE STATEMENT
  • instead of the action on the row
  • for views
  • operation execution
  • Return value
    • BEFORE ROW
    • INSTEAD OF ROW
    • a row (possibly modified)
    • is shown in RETURNING
    • null cancels the action
  • Context
    • OLD
    • update, delete
    • NEW
    • insert, update
    • TG variables

Speaker notes

INSTEAD OF triggers are very similar to BEFORE triggers, but they can be defined only for views and fire instead of the specified operation, not before it.

Such triggers usually perform operations on the base tables for views. The trigger can also return a modified NEW value: this value will be available when performing an operation with the RETURNING clause.

Page 8: After Row

Triggers

  • BEFORE STATEMENT
  • after operation execution
  • queue of rows satisfying the WHEN condition
  • operation execution
  • Return value
    • BEFORE ROW
    • INSTEAD OF ROW
    • is ignored
  • Context
    • OLD, OLD TABLE
    • update, delete
    • NEW, NEW TABLE insert, update
    • TG variables
    • AFTER ROW

Speaker notes

Just as BEFORE ROW, AFTER ROW triggers fire for each affected row; but it happens only after the whole operation is complete, not immediately after processing the row. This ensures that when these triggers access the table being modified, the result does not depend on the order of row processing. For this purpose, all events are placed in a queue and processed after the operation has finished. The fewer events get queued, the smaller overhead will be incurred; that’s why it is recommended to use the WHEN clause in this case, which can filter out the rows that we definitely won’t need.

The return value of AFTER ROW triggers is ignored (because the operation is already complete).

The context of the trigger function is constituted by the following variables:

  • OLD — an old state of the row (undefined for insertion)

  • NEW — an updated value of the row (undefined for deletion)

Apart from these variables, the trigger function can access special transition tables. The table specified as OLD TABLE when creating the trigger contains the old values of the rows processed by the trigger, and the NEW TABLE contains the new values of the same rows.

Regular TG variables are also available, including the following ones:

● TG_WHEN = AFTER

● TG_LEVEL = ROW

● TG_OP = INSERT/UPDATE/DELETE

Page 9: After Statement

Triggers

  • BEFORE STATEMENT
  • after the operation
  • (even if none of the rows are affected)
  • operation execution
  • BEFORE ROW
  • INSTEAD OF ROW
  • Return value
    • is ignored
  • Context
    • OLD TABLE
    • update, delete
    • NEW TABLE
    • insert, update
    • TG variables
    • AFTER ROW
    • AFTER STATEMENT

Speaker notes

The AFTER STATEMENT trigger fires after the operation has completed (including all the AFTER ROW triggers, if any). This trigger fires only once regardless of the number of the affected rows.

The return value of the trigger function is ignored.

The call context is passed using transition tables. The trigger function can access these tables to analyze all the affected rows. Transition tables are usually used with AFTER STATEMENT, not with AFTER ROW triggers.

Besides, regular TG variables are defined, such as:

● TG_WHEN = AFTER

● TG_LEVEL = STATEMENT

● TG_OP = INSERT/UPDATE/DELETE/TRUNCATE

etc.

Page 10: Trigger Firing Order

Let’s create a “universal” trigger function that describes the context in which it is called. The context is passed in various TG variables. We are going to define triggers for various events and observe the order in which the triggers are fired during execution.

=> CREATE OR REPLACE FUNCTION describe() RETURNS trigger
AS $$
DECLARE
    rec record;
    str text := '';
BEGIN
    IF TG_LEVEL = 'ROW' THEN
        CASE TG_OP
            WHEN 'DELETE' THEN rec := OLD; str := OLD::text;
            WHEN 'UPDATE' THEN rec := NEW; str := OLD || ' -> ' || NEW;
            WHEN 'INSERT' THEN rec := NEW; str := NEW::text;
        END CASE;
    END IF;
    RAISE NOTICE '% % % %: %',
        TG_TABLE_NAME, TG_WHEN, TG_OP, TG_LEVEL, str;
    RETURN rec;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

A table:

=> CREATE TABLE t(
    id integer PRIMARY KEY,
    s text
);
CREATE TABLE

Triggers at the statement level:

=> CREATE TRIGGER t_before_stmt
BEFORE INSERT OR UPDATE OR DELETE -- events
ON t                              -- table
FOR EACH STATEMENT                -- level
EXECUTE FUNCTION describe();      -- trigger function
CREATE TRIGGER
=> CREATE TRIGGER t_after_stmt
AFTER INSERT OR UPDATE OR DELETE ON t
FOR EACH STATEMENT EXECUTE FUNCTION describe();
CREATE TRIGGER

Triggers at the row level:

=> CREATE TRIGGER t_before_row
BEFORE INSERT OR UPDATE OR DELETE ON t
FOR EACH ROW EXECUTE FUNCTION describe();
CREATE TRIGGER
=> CREATE TRIGGER t_after_row
AFTER INSERT OR UPDATE OR DELETE ON t
FOR EACH ROW EXECUTE FUNCTION describe();
CREATE TRIGGER

Let’s perform an INSERT:

=> INSERT INTO t VALUES (1,'aaa'), (2, 'bbb');
NOTICE:  t BEFORE INSERT STATEMENT:
NOTICE:  t BEFORE INSERT ROW: (1,aaa)
NOTICE:  t BEFORE INSERT ROW: (2,bbb)
NOTICE:  t AFTER INSERT ROW: (1,aaa)
NOTICE:  t AFTER INSERT ROW: (2,bbb)
NOTICE:  t AFTER INSERT STATEMENT:
INSERT 0 2

Page 11: Page 11

Now run an UPDATE:

=> UPDATE t SET s = 'ccc' WHERE id = 1;
NOTICE:  t BEFORE UPDATE STATEMENT:
NOTICE:  t BEFORE UPDATE ROW: (1,aaa) -> (1,ccc)
NOTICE:  t AFTER UPDATE ROW: (1,aaa) -> (1,ccc)
NOTICE:  t AFTER UPDATE STATEMENT:
UPDATE 1

Statement-level triggers will fire even if the command has not processed any rows at all:

=> UPDATE t SET s = 'ddd' WHERE id = 0;
NOTICE:  t BEFORE UPDATE STATEMENT:
NOTICE:  t AFTER UPDATE STATEMENT:
UPDATE 0

Here is a subtle point: the INSERT statement with the ON CONFLICT clause activates BEFORE triggers both on insert and update:

=> INSERT INTO t VALUES (1,'ddd'), (3,'eee')
ON CONFLICT(id) DO UPDATE SET s = EXCLUDED.s;
NOTICE:  t BEFORE INSERT STATEMENT:
NOTICE:  t BEFORE UPDATE STATEMENT:
NOTICE:  t BEFORE INSERT ROW: (1,ddd)
NOTICE:  t BEFORE UPDATE ROW: (1,ccc) -> (1,ddd)
NOTICE:  t BEFORE INSERT ROW: (3,eee)
NOTICE:  t AFTER UPDATE ROW: (1,ccc) -> (1,ddd)
NOTICE:  t AFTER INSERT ROW: (3,eee)
NOTICE:  t AFTER UPDATE STATEMENT:
NOTICE:  t AFTER INSERT STATEMENT:
INSERT 0 2

And finally, let’s try out a DELETE:

=> DELETE FROM t WHERE id = 2;
NOTICE:  t BEFORE DELETE STATEMENT:
NOTICE:  t BEFORE DELETE ROW: (2,bbb)
NOTICE:  t AFTER DELETE ROW: (2,bbb)
NOTICE:  t AFTER DELETE STATEMENT:
DELETE 1

There is no dedicated trigger type for the MERGE statement (introduced in PostgreSQL 15), use regular triggers for UPDATE, INSERT, DELETE:

=> MERGE INTO t
USING (VALUES (1, 'fff'), (3, 'ggg'), (4, 'hhh')) AS vals(id, s)
ON t.id = vals.id
WHEN MATCHED AND t.id = 1 THEN
  UPDATE SET s = vals.s
WHEN MATCHED THEN
  DELETE
WHEN NOT MATCHED THEN
  INSERT (id, s)
  VALUES (vals.id, vals.s);
NOTICE:  t BEFORE INSERT STATEMENT:
NOTICE:  t BEFORE UPDATE STATEMENT:
NOTICE:  t BEFORE DELETE STATEMENT:
NOTICE:  t BEFORE UPDATE ROW: (1,ddd) -> (1,fff)
NOTICE:  t BEFORE DELETE ROW: (3,eee)
NOTICE:  t BEFORE INSERT ROW: (4,hhh)
NOTICE:  t AFTER UPDATE ROW: (1,ddd) -> (1,fff)
NOTICE:  t AFTER DELETE ROW: (3,eee)
NOTICE:  t AFTER INSERT ROW: (4,hhh)
NOTICE:  t AFTER DELETE STATEMENT:
NOTICE:  t AFTER UPDATE STATEMENT:
NOTICE:  t AFTER INSERT STATEMENT:
MERGE 3

Transition Tables

Let’s create a trigger function that shows the contents of transition tables. Here we use old_table and new_table names: they will be declared as part of the trigger definition. Transition tables look just like regular ones, but they are not included into the system catalog and are located in RAM (although

Page 12: Page 12

they can be flushed to disk if they get too large).

=> CREATE OR REPLACE FUNCTION transition() RETURNS trigger
AS $$
DECLARE
    rec record;
BEGIN
    IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN
        RAISE NOTICE 'Old state:';
        FOR rec IN SELECT * FROM old_table LOOP
            RAISE NOTICE '%', rec;
        END LOOP;
    END IF;
    IF TG_OP = 'UPDATE' OR TG_OP = 'INSERT' THEN
        RAISE NOTICE 'New state:';
        FOR rec IN SELECT * FROM new_table LOOP
            RAISE NOTICE '%', rec;
        END LOOP;
    END IF;
    RETURN NULL;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

Let’s create a new table:

=> CREATE TABLE trans(
    id integer PRIMARY KEY,
    n integer
);
CREATE TABLE
=> INSERT INTO trans VALUES (1,10), (2,20), (3,30);
INSERT 0 3

To create transition tables for an operation, you have to specify their names in the trigger definition:

=> CREATE TRIGGER t_after_upd_trans
AFTER UPDATE ON trans -- one event per trigger
REFERENCING
    OLD TABLE AS old_table -- it is OK to specify only one table,
    NEW TABLE AS new_table -- there is no need to provide both
FOR EACH STATEMENT
EXECUTE FUNCTION transition();
CREATE TRIGGER

Let’s check the result:

=> UPDATE trans SET n = n + 1 WHERE n <= 20;
NOTICE:  Old state:
NOTICE:  (1,10)
NOTICE:  (2,20)
NOTICE:  New state:
NOTICE:  (1,11)
NOTICE:  (2,21)
UPDATE 2

Transition tables contain only those rows that have been affected by the operation. In addition to updates, transition tables are also supported for INSERT and DELETE operations, although only one table will be available at a time: OLD TABLE is unavailable for inserts, while NEW TABLE is unavailable for deletes. Since AFTER ROW triggers fire after the whole operation is completed, they can also use transition tables. But there is usually no point in it.

Page 13: Possible Use Cases

  • operation applicability checks
  • BEFORE STATEMENT
    • validation,
    • row modifications
  • operation execution
  • BEFORE ROW
    • changing the base tables for views
  • INSTEAD OF ROW
    • consistency checks,
    • including table-level checks;
    • logging operations for audit purposes;
    • cascading table updates
    • (denormalization, asynchronous
    • processing…)
  • AFTER ROW
  • AFTER STATEMENT

Speaker notes

What are triggers actually used for?

BEFORE triggers can be used to check if the operation is valid and to raise errors if required.

BEFORE ROW triggers can be used to modify a row (for example, fill an empty field with the required value). It is convenient to use such triggers to avoid repeating the logic of filling out “technical” fields in each operation, as well as tweak the application behavior if its code cannot be modified.

INSTEAD OF ROW triggers are used to translate operations on views into the corresponding operations on the underlying base tables.

AFTER ROW and AFTER STATEMENT triggers can be useful for getting the exact state after the operation (BEFORE triggers may affect the result, so the state is not yet clear at this stage):

● To check consistency of the operation.

● To perform audit operations, i.e., logging all changes in a separate storage.

● To cascade changes to other tables (for example, to update denormalized data if the base tables have changed, or queue changes for subsequent processing outside of the current transaction).

If the operation affects multiple rows, it may be more efficient to use AFTER STATEMENT on transition tables instead of AFTER ROW as it can process changes in a batch.

Page 14: Challenges

The code is called implicitly

  • the execution logic is hard to track
  • Visibility rules for volatile trigger functions
    • the result of BEFORE ROW and INSTEAD OF ROW triggers is visible
  • The order of calling triggers for one and the same event
    • triggers fire in the alphabetical order
  • Infinite looping can occur
    • a trigger can activate other triggers
  • Integrity constraints can be broken
    • for example, by excluding rows that have to be deleted

Speaker notes

Triggers should not be overused. As they fire implicitly, the logic of the application becomes obscure, thus making its maintenance hugely complicated. Attempts to use triggers for implementing complex logic are usually quite unfortunate.

In some cases, you can use generated columns instead of triggers (GENERATED ALWAYS AS … STORED). If applicable, this solution is sure to be more transparent and easier to implement.

There is a number of subtle points related to using triggers; we consciously skip their detailed discussion here:

● Visibility rules of volatile functions in BEFORE ROW and INSTEAD OF ROW triggers (do not rely on the order of triggers when accessing a table)

● The order of calling several triggers on one and the same event (do not aggravate implicit firing of triggers by relying on their exact processing sequence)

● A possibility of infinite looping if cascade firing of triggers leads to another activation of the first trigger

● A risk of integrity constraint violation (for example, referential integrity can be compromised when skipping a row deleted by the ON DELETE CASCADE condition)

If you see that these subtleties are important for your application, you should seriously consider redesigning it.

Page 15: Examples of Using Triggers

Example 1: saving the history of row changes. Suppose we have a table that contains the current data. The task is to save the main table’s history of all row changes into a separate table. Historical table support could be delegated to the application, but chances are high that some part of the history won’t be saved if an error occurs. That’s why we are going to solve this problem using triggers. As an example, we will create two tables: one with actual, up-to-date data, and the other with historical data tracking all changes made to the first table. The main table will store various British coins and the materials they are made of:

=> CREATE TABLE coins(
    name text PRIMARY KEY,
    material text
);
CREATE TABLE

Page 16: Event Triggers

Event trigger

  • is similar to a regular trigger defined on a table, but is a separate object
  • Trigger function
    • convention: a function does not take any parameters,
    • returns a value of the event_trigger pseudotype
    • the call context is retrieved using special functions
  • Events
    • DDL_COMMAND_START
    • before command execution
    • DDL_COMMAND_END
    • after command execution
    • TABLE_REWRITE
    • before rewriting the table
    • SQL_DROP
    • after deleting objects

Speaker notes

Event triggers are virtually the same as regular triggers, but instead of firing on DML operations, they fire on DDL operations (CREATE, ALTER, DROP, COMMENT, GRANT, REVOKE).

Such triggers are not an application development tool; they are mainly used for database administration purposes. We only mention them here to give you a complete picture, so we’ll provide a very basic example.

https://postgrespro.com/docs/postgresql/16/event-trigger-definition

https://postgrespro.com/docs/postgresql/16/event-trigger-matrix

https://postgrespro.com/docs/postgresql/16/sql-createeventtrigger

https://postgrespro.com/docs/postgresql/16/functions-event-triggers

Page 17: Page 17

Page 18: Takeaways

A trigger is a way to address a particular event

  • Using triggers, you can cancel an operation, modify its outcome, or perform additional actions
  • Triggers are executed as part of the main transaction; an error in a trigger aborts this transaction
  • Using AFTER ROW triggers and transition tables makes processing more expensive
  • Everything is good in moderation: complex logic is hard to debug because of implicit trigger execution

Page 19: Practice

  1. Create a trigger that handles updates of the onhand_qty field in the catalog_v view.
  • Check that the Catalog tab now allows ordering books. 2. Make sure that the following consistency requirement is met: the amount of available books cannot be negative (it is impossible to buy a book if it is not in stock).
    • Check your implementation carefully, keeping in mind that the
    • application can be accessed by several users simultaneously.

Speaker notes

  1. It may seem that it’s enough to define the AFTER trigger on the operations table to calculate the qty_change sum. However, at the READ COMMITTED isolation level used in the Bookstore application, we will have to acquire an exclusive lock on this table: otherwise, the check may not function properly in some scenarios.

Here is a better approach: extend the books table with the onhand_qty column and create a trigger that will be modifying onhand_qty values when the operations table is changed (i.e., you should virtually perform data denormalization). You can now define the CHECK constraint on the onhand_qty field to ensure data consistency. The onhand_qty() function created earlier is no longer required.

You should pay special attention to setting the initial value, keeping in mind that the database system may be serving some users while we apply these changes.

Page 20: 1. Using a Trigger to Update the Catalog

=> CREATE FUNCTION update_catalog() RETURNS trigger
AS $$
BEGIN
    INSERT INTO operations(book_id, qty_change) VALUES
        (OLD.book_id, NEW.onhand_qty - coalesce(OLD.onhand_qty,0));
    RETURN NEW;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE TRIGGER update_catalog_trigger
INSTEAD OF UPDATE ON catalog_v
FOR EACH ROW
EXECUTE FUNCTION update_catalog();
CREATE TRIGGER

2. Checking the Quantity of Books

Let’s extend the table with the column that will store the quantity of available books.

=> ALTER TABLE books ADD COLUMN onhand_qty integer;
ALTER TABLE

The trigger function for the AFTER trigger, which is fired on insertion to update the quantity of available books (we assume that the onhand_qty field cannot be empty):

=> CREATE FUNCTION update_onhand_qty() RETURNS trigger
AS $$
BEGIN
    UPDATE books
    SET onhand_qty = onhand_qty + NEW.qty_change
    WHERE book_id = NEW.book_id;
    RETURN NULL;
END
$$ VOLATILE LANGUAGE plpgsql;
CREATE FUNCTION

The remaining operations are performed within a single transaction.

=> BEGIN;
BEGIN

Locking the table for the duration of the transaction:

=> LOCK TABLE operations;
LOCK TABLE

Providing the initial value:

=> UPDATE books b
SET onhand_qty = (
    SELECT coalesce(sum(qty_change),0)
    FROM operations o
    WHERE o.book_id = b.book_id
);
UPDATE 7

Defining constraints now that the field is non-empty:

=> ALTER TABLE books ALTER COLUMN onhand_qty SET DEFAULT 0;
ALTER TABLE
=> ALTER TABLE books ALTER COLUMN onhand_qty SET NOT NULL;
ALTER TABLE
=> ALTER TABLE books ADD CHECK(onhand_qty >= 0);
ALTER TABLE

Page 21: Page 21

Creating a trigger:

=> CREATE TRIGGER update_onhand_qty_trigger
AFTER INSERT ON operations
FOR EACH ROW
EXECUTE FUNCTION update_onhand_qty();
CREATE TRIGGER

Done.

=> COMMIT;
COMMIT

Now the books.onhand_qty column is being updated, but the catalog_v view still calls a function to calculate the number of books. Although the syntax used for function access in the initial query is the same as that for the field access, the query has been stored in a different form:

=> \d+ catalog_v
                            View "bookstore.catalog_v"
    Column    |  Type   | Collation | Nullable | Default | Storage  | Description
--------------+---------+-----------+----------+---------+----------+-------------
 book_id      | integer |           |          |         | plain    |
 title        | text    |           |          |         | extended |
 onhand_qty   | integer |           |          |         | plain    |
 display_name | text    |           |          |         | extended |
 authors      | text    |           |          |         | extended |
View definition:
 SELECT book_id,
    title,
    onhand_qty(b.*) AS onhand_qty,
    book_name(book_id, title) AS display_name,
    authors(b.*) AS authors
   FROM books b
  ORDER BY (book_name(book_id, title));
Triggers:
    update_catalog_trigger INSTEAD OF UPDATE ON catalog_v FOR EACH ROW EXECUTE FUNCTION
update_catalog()

Let’s replace the view:

=> CREATE OR REPLACE VIEW catalog_v AS
SELECT b.book_id,
       b.title,
       b.onhand_qty,
       book_name(b.book_id, b.title) AS display_name,
       b.authors
FROM   books b
ORDER BY display_name;
CREATE VIEW

Now the function can be deleted.

=> DROP FUNCTION onhand_qty(books);
DROP FUNCTION

Let’s run a small check:

=> SELECT * FROM catalog_v WHERE book_id = 1 \gx
-[ RECORD 1 ]+----------------------------------------------
book_id      | 1
title        | The Tale of Tsar Saltan
onhand_qty   | 19
display_name | The Tale of Tsar Saltan. Alexander S. Pushkin
authors      | Alexander Sergeyevich Pushkin
=> INSERT INTO operations(book_id, qty_change) VALUES (1,+10);
INSERT 0 1
=> SELECT * FROM catalog_v WHERE book_id = 1 \gx

Page 22: Page 22

-[ RECORD 1 ]+----------------------------------------------
book_id      | 1
title        | The Tale of Tsar Saltan
onhand_qty   | 29
display_name | The Tale of Tsar Saltan. Alexander S. Pushkin
authors      | Alexander Sergeyevich Pushkin

Invalid operations are rejected:

=> INSERT INTO operations(book_id, qty_change) VALUES (1,-100);
ERROR:  new row for relation "books" violates check constraint "books_onhand_qty_check"
DETAIL:  Failing row contains (1, The Tale of Tsar Saltan, -71).
CONTEXT:  SQL statement "UPDATE books
    SET onhand_qty = onhand_qty + NEW.qty_change
    WHERE book_id = NEW.book_id"
PL/pgSQL function update_onhand_qty() line 3 at SQL statement

Page 23: Practice+

  1. Create a trigger that increments the counter (the version field) by 1 each time a row is updated. If a new row is inserted, the corresponding counter must be set to 1. Check the implementation. 2. There are two tables: orders (to store orders) and lines (to keep order lines). Denormalization is required here: automatically update the total cost of the order in the orders table once the corresponding lines have changed.
  • Create the required triggers using transition tables to minimize
  • the number of update operations.

Speaker notes

  1. Use the following commands to create tables:
CREATE TABLE orders (
id int PRIMARY KEY,
total_amount numeric(20,2) NOT NULL DEFAULT 0
);
CREATE TABLE lines (
id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_id int NOT NULL REFERENCES orders(id),
amount numeric(20,2) NOT NULL
);

The orders.total_amount column must be calculated automatically as the sum of lines.amount values for all rows related to the corresponding order.

Page 24: 1. Version Counter

=> CREATE DATABASE plpgsql_triggers;
CREATE DATABASE
=> \c plpgsql_triggers
You are now connected to database "plpgsql_triggers" as user "student".

Table:

=> CREATE TABLE t(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    s text,
    version integer
);
CREATE TABLE

Trigger function:

=> CREATE FUNCTION inc_version() RETURNS trigger
AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        NEW.version := 1;
    ELSE
        NEW.version := OLD.version + 1;
    END IF;
    RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

Trigger:

=> CREATE TRIGGER t_inc_version
BEFORE INSERT OR UPDATE ON t
FOR EACH ROW EXECUTE FUNCTION inc_version();
CREATE TRIGGER

Test it:

=> INSERT INTO t(s) VALUES ('One');
INSERT 0 1
=> SELECT * FROM t;
 id |  s  | version
----+-----+---------
  1 | One |       1
(1 row)

Attempting to set version directly is ignored.

=> INSERT INTO t(s,version) VALUES ('Two',42);
INSERT 0 1
=> SELECT * FROM t;
 id |  s  | version
----+-----+---------
  1 | One |       1
  2 | Two |       1
(2 rows)

Update:

=> UPDATE t SET s = lower(s) WHERE id = 1;
UPDATE 1
=> SELECT * FROM t;

Page 25: Page 25

 id |  s  | version
----+-----+---------
  2 | Two |       1
  1 | one |       2
(2 rows)

This attempt is also ignored.

=> UPDATE t SET s = lower(s), version = 42 WHERE id = 2;
UPDATE 1
=> SELECT * FROM t;
 id |  s  | version
----+-----+---------
  1 | one |       2
  2 | two |       2
(2 rows)

2. Calculating the Total of Orders Automatically

Create tables with a structure simple enough for the demonstration:

=> CREATE TABLE orders (
    id integer PRIMARY KEY,
    total_amount numeric(20,2) NOT NULL DEFAULT 0
);
CREATE TABLE
=> CREATE TABLE lines (
   id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
   order_id integer NOT NULL REFERENCES orders(id),
   amount numeric(20,2) NOT NULL
);
CREATE TABLE

Create a trigger function and a trigger to handle inserts:

=> CREATE FUNCTION total_amount_ins() RETURNS trigger
AS $$
BEGIN
    WITH l(order_id, total_amount) AS (
        SELECT order_id, sum(amount)
        FROM new_table
        GROUP BY order_id
    )
    UPDATE orders o
    SET total_amount = o.total_amount + l.total_amount
    FROM l
    WHERE o.id = l.order_id;
    RETURN NULL;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

The FROM clause in the UPDATE command joins orders to a subquery in a transition table and use the columns of the subquery to calculate the value.

=> CREATE TRIGGER lines_total_amount_ins
AFTER INSERT ON lines
REFERENCING
    NEW TABLE AS new_table
FOR EACH STATEMENT
EXECUTE FUNCTION total_amount_ins();
CREATE TRIGGER

A function and a trigger for processing updates:

Page 26: Page 26

=> CREATE FUNCTION total_amount_upd() RETURNS trigger
AS $$
BEGIN
    WITH l_tmp(order_id, amount) AS (
        SELECT order_id, amount FROM new_table
        UNION ALL
        SELECT order_id, -amount FROM old_table
    ), l(order_id, total_amount) AS (
        SELECT order_id, sum(amount)
        FROM l_tmp
        GROUP BY order_id
        HAVING sum(amount) <> 0
    )
    UPDATE orders o
    SET total_amount = o.total_amount + l.total_amount
    FROM l
    WHERE o.id = l.order_id;
    RETURN NULL;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

The HAVING clause skips changes that do not affect the total amount.

=> CREATE TRIGGER lines_total_amount_upd
AFTER UPDATE ON lines
REFERENCING
    OLD TABLE AS old_table
    NEW TABLE AS new_table
FOR EACH STATEMENT
EXECUTE FUNCTION total_amount_upd();
CREATE TRIGGER

A function and a trigger for handling deletion:

=> CREATE FUNCTION total_amount_del() RETURNS trigger
AS $$
BEGIN
    WITH l(order_id, total_amount) AS (
        SELECT order_id, -sum(amount)
        FROM old_table
        GROUP BY order_id
    )
    UPDATE orders o
    SET total_amount = o.total_amount + l.total_amount
    FROM l
    WHERE o.id = l.order_id;
    RETURN NULL;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE TRIGGER lines_total_amount_del
AFTER DELETE ON lines
REFERENCING
    OLD TABLE AS old_table
FOR EACH STATEMENT
EXECUTE FUNCTION total_amount_del();
CREATE TRIGGER

Only TRUNCATE is left out, since its trigger cannot use transition tables. However, we know that after a TRUNCATE, the lines table will no longer contain any lines, so the order total can be wiped.

=> CREATE FUNCTION total_amount_truncate() RETURNS trigger
AS $$
BEGIN
    UPDATE orders SET total_amount = 0;
    RETURN NULL;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE TRIGGER lines_total_amount_truncate
AFTER TRUNCATE ON lines
FOR EACH STATEMENT
EXECUTE FUNCTION total_amount_truncate();
CREATE TRIGGER

Page 27: Page 27

Additionally, total_amount must not be changed manually, but this cannot be solved with triggers. Test it. Add two new orders with no lines:

=> INSERT INTO orders VALUES (1), (2);
INSERT 0 2
=> SELECT * FROM orders ORDER BY id;
 id | total_amount
----+--------------
  1 |         0.00
  2 |         0.00
(2 rows)

Add lines to orders:

=> INSERT INTO lines (order_id, amount) VALUES
    (1,100), (1,100), (2,500), (2,500);
INSERT 0 4
=> SELECT * FROM lines;
 id | order_id | amount
----+----------+--------
  1 |        1 | 100.00
  2 |        1 | 100.00
  3 |        2 | 500.00
  4 |        2 | 500.00
(4 rows)
=> SELECT * FROM orders ORDER BY id;
 id | total_amount
----+--------------
  1 |       200.00
  2 |      1000.00
(2 rows)

Double the amounts in all lines in all orders:

=> UPDATE lines SET amount = amount * 2;
UPDATE 4
=> SELECT * FROM orders ORDER BY id;
 id | total_amount
----+--------------
  1 |       400.00
  2 |      2000.00
(2 rows)

Remove the first line of the first order:

=> DELETE FROM lines WHERE id = 1;
DELETE 1
=> SELECT * FROM orders ORDER BY id;
 id | total_amount
----+--------------
  1 |       200.00
  2 |      2000.00
(2 rows)

Clear the lines table:

=> TRUNCATE lines;
TRUNCATE TABLE
=> SELECT * FROM orders ORDER BY id;

Page 28: Page 28

 id | total_amount
----+--------------
  1 |         0.00
  2 |         0.00
(2 rows)
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_triggers;
DROP DATABASE

18 Lesson 18: PL/pgSQL / Debugging

Native Markdown source page: PL/pgSQL / Debugging
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_18_plpgsql_debug.html Archive: DEV1-handouts-16-en.zip PDF pages: 28

Page 1: PL/pgSQL / Debugging

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

In no event shall Postgres Professional company be liable for any damages or loss, including loss of profits, that arise from direct or indirect, special or incidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Correctness Checks
  • PL/pgSQL Debugger
  • Debug Messages and their Implementations
  • Session Tracing

Page 3: Correctness Checks

Сompile-time and runtime checks

  • plpgsql.extra_warnings
  • plpgsql.extra_errors
  • additional checks provided by the plpgsql_check extension
  • Built-in checks
    • ASSERT statement
  • Testing

Speaker notes

Debugging implies executing a program and analyzing the occurred issues, typically by running a debugger or by displaying debug messages.

But you can also avoid some particular error classes if you enable compile-time and runtime verification of source code. It is controlled by plpgsql.extra_warnings and plpgsql.extra_errors parameters, as explained in the PL/pgSQL. Query execution lesson.

https://postgrespro.com/docs/postgresql/16/plpgsql-development-tips#PLPG SQL-EXTRA-CHECKS

The extension plpgsql_check, mentioned earlier in the course, offers a wider range of checks.

Another way to make your code more secure is to check for conditions that must always hold true (the so-called sanity checks). A convenient way to do it is to use the ASSERT statement.

https://postgrespro.com/docs/postgresql/16/plpgsql-errors-and-messages#P LPGSQL-STATEMENTS-ASSERT

We must also mention the importance of testing the code. Apart from making sure from the very beginning that the code works as expected, testing also facilitates further maintenance: it ensures that the existing functionality is not broken by the introduced changes. We will not expand on this topic; but it’s worth noting that testing the code that accesses a database can turn out to be quite tricky as you have to prepare test cases.

Page 4: Correctness Checks

=> CREATE DATABASE plpgsql_debug;
CREATE DATABASE
=> \c plpgsql_debug
You are now connected to database "plpgsql_debug" as user "student".

The ASSERT statement specifies conditions that, when violated, raise an error. These are somewhat similar to database integrity constraints. Example: a function that takes an apartment number in a block and returns the entrance number:

=> CREATE FUNCTION entrance(
    floors integer,
    flats_per_floor integer,
    flat_no integer
)
RETURNS integer
AS $$
BEGIN
    RETURN floor((flat_no - 1)::real / (floors * flats_per_floor)) + 1;
END
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Let’s test the function by providing some sample values:

=> SELECT entrance(9, 4, 1), entrance(9, 4, 36), entrance(9, 4, 37);
 entrance | entrance | entrance
----------+----------+----------
        1 |        1 |        2
(1 row)

But with some invalid input values, the function will return invalid output. This output may then get forwarded to other programs, possibly breaking them too. Testing only the function code here will not suffice.

=> SELECT entrance(9, 4, 0);
 entrance
----------
        0
(1 row)

For additional safety, let’s add a check:

=> CREATE OR REPLACE FUNCTION entrance(
    floors integer,
    flats_per_floor integer,
    flat_no integer
)
RETURNS integer
AS $$
BEGIN
    ASSERT floors > 0 AND flats_per_floor > 0 AND flat_no > 0,
        'Bad input';
    RETURN floor((flat_no - 1)::real / (floors * flats_per_floor)) + 1;
END
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION
=> SELECT entrance(9, 4, 0);
ERROR:  Bad input
CONTEXT:  PL/pgSQL function entrance(integer,integer,integer) line 3 at ASSERT

Now, a call with incorrect parameters will immediately return an error.

Page 5: PL/pgSQL Debugger

Interface

  • the API is provided as an extension (pldbgapi)
  • built-in support is available in some GUIs
  • Features
    • setting breakpoints
    • step-by-step execution
    • checking and setting variable values
    • no need to modify the code
    • debugging applications on the fly

Speaker notes

As its name suggests, PL/pgSQL Debugger is a debugging tool for PL/pgSQL. It is delivered as the pldbgapi extension, which is officially supported by PostgreSQL developers.

The pldbgapi extension is a collection of interface functions for the PostgreSQL server that enable you to set breakpoints, execute the application code step-by-step, check and set variable values.

There is no need to modify the source code of the application to debug, so debugging can be performed on the fly. In other words, you do not have to restart the process with an attached debugger, you can simply connect to the current session and start debugging it.

It is inconvenient to use these functions directly; they are mainly targeted for IDEs with graphical user interface. Some of these IDEs (including DBeaver) have a convenient built-in debugging user interface. But in order to use it, you still have to install the pldbgapi extension into the corresponding database first.

The source code of pldbgapi is available at:

https://github.com/EnterpriseDB/pldebugger

Page 6: Debugging with PL/pgSQL Debugger

Let’s debug one of the functions of our application. The VM comes with the debugger support package postgresql-16-pldebugger already installed. The debugger needs its shared library to be loaded. Add it to the list and restart the server:

=> ALTER SYSTEM SET shared_preload_libraries = 'plugin_debugger';
ALTER SYSTEM
student$ sudo pg_ctlcluster 16 main restart

Install the debugger extension into the bookstore database:

student$ psql bookstore
=> CREATE EXTENSION pldbgapi SCHEMA public;
CREATE EXTENSION

We will use DBeaver, a GUI tool, for debugging.

student$ /opt/dbeaver/dbeaver 2>/dev/null

Page 7: Debug Messages

Not only debugging

  • monitoring of long-running processes
  • application logging
  • Implementation strategies
    • monitoring long-running processes
    • writing messages to a table or a file
    • passing information to other processes

Speaker notes

Another debugging approach consists in adding debug messages to the key parts of the code to provide the current context. As you analyze these messages, you can understand what exactly has gone wrong.

Apart from the debugging itself, debug messages can also perform other functions. Messages help to track down what stage the process is currently in. Similar to a database log, an application can write its own log. Having such a log with important data (for example, report-related data: the name of the report, the user who has collected it, date, arguments, etc.) can greatly facilitate technical support.

We can single out several strategies of implementing debug messages in PL/pgSQL. Apart from using the already familiar RAISE command, which can display messages in the terminal (or save them into the server log), it is also possible to send messages to another process, as well as write them into a table or into a file.

When choosing the approach to use, you have to take a lot of different aspects into account. Are messages transactional (are they sent before the end of the transaction or only after it has been committed)? Can you send them from several sessions simultaneously? How can you set up access to the log file and clean up old log entries? How does logging affect performance? Do you have to modify the source code?

Page 8: RAISE Statement

Source slide page 8: RAISE Statement{#source-figure-008 caption=“Source slide page 8: RAISE Statement”}

DEBUG LOG NOTICE WARNING ERROR FATAL PANIC
DEBUG
LOG
NOTICE
RAISE
INFO
WARNING
DEBUG INFO NOTICE WARNING ERROR LOG FATAL PANIC

Speaker notes

We are already familiar with the RAISE statement. It can be used both to raise exceptions (which is discussed in detail in the PL/pgSQL. Error Handling lesson) and to emit messages. Such messages can not only be sent to the client, but also written to the server log.

In a simple debugging case, you have to add RAISE NOTICE calls to the function code, start the function execution (for example, in a psql session), and analyze the received messages as the execution progresses. RAISE messages are non-transactional: they are emitted asynchronously and do not depend on the transaction status.

Message delivery is controlled by message levels (DEBUG, LOG, NOTICE, INFO, WARNING) and server parameters. Parameter values determine whether a message will be sent to the client (client_min_messages) and/or written to the server log (log_min_messages). A message will be sent if the RAISE message level is not lower than the value of the corresponding parameter (is shown to the right of the parameter value on this slide).

In the default configuration, NOTICE messages are only sent to the client, LOG messages are only written to the log file, and WARNING messages are both sent to the client and written to the log file.

INFO messages are always sent to the client; they cannot be trapped using the client_min_messages parameter.

https://postgrespro.com/docs/postgresql/16/plpgsql-errors-and-messages

Page 9: RAISE Statement

Let’s create a function that takes a table name as input and returns the number of rows in that table.

student$ psql plpgsql_debug
=> CREATE FUNCTION get_count(tabname text) RETURNS bigint
AS $$
DECLARE
    cmd text;
    retval bigint;
BEGIN
    cmd := 'SELECT COUNT(*) FROM ' || quote_ident(tabname);
    RAISE NOTICE 'cmd: %', cmd;
    EXECUTE cmd INTO retval;
    RETURN retval;
END
$$ LANGUAGE plpgsql STABLE;
CREATE FUNCTION

For dynamic execution, it is better to save the command text to a variable. In case of an error, you can analyze the variable value.

=> SELECT get_count('pg_class');
NOTICE:  cmd: SELECT COUNT(*) FROM pg_class
 get_count
-----------
       413
(1 row)

The string starting with “NOTICE” is our debug information. RAISE can be used to track the progress of a long query. Let’s consider a piece of code that has three distinct stages. As the code executes, we want to understand which stage we are at. To do this, we will call this procedure at each stage:

=> CREATE PROCEDURE debug_message(msg text)
AS $$
BEGIN
RAISE NOTICE '%', msg;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Upon completing each stage, the main procedure calls debug_message:

=> CREATE PROCEDURE long_running()
AS $$
BEGIN
    CALL debug_message('long_running. Stage 1/3...');
    PERFORM pg_sleep(2);
    CALL debug_message('long_running. Stage 2/3...');
    PERFORM pg_sleep(3);
    CALL debug_message('long_running. Stage 3/3...');
    PERFORM pg_sleep(1);
    CALL debug_message('long_running. Done');
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

The RAISE statement issues messages immediately, rather than at the end of the main procedure:

=> CALL long_running();
NOTICE:  long_running. Stage 1/3...
NOTICE:  long_running. Stage 2/3...
NOTICE:  long_running. Stage 3/3...
NOTICE:  long_running. Done
CALL

This approach works fine when you call a function within a session. If, however, the function is called by an application, writing to and then reading from the server message log is much more convenient.

Page 10: Page 10

Let’s make debug_message emit a message with a specific level. The level will be defined by a custom parameter app.raise_level:

=> CREATE OR REPLACE PROCEDURE debug_message(msg text)
AS $$
BEGIN
    CASE current_setting('app.raise_level', true)
        WHEN 'NOTICE'  THEN RAISE NOTICE  '%, %, %', user, clock_timestamp(), msg;
        WHEN 'DEBUG'   THEN RAISE DEBUG   '%, %, %', user, clock_timestamp(), msg;
        WHEN 'LOG'     THEN RAISE LOG     '%, %, %', user, clock_timestamp(), msg;
        WHEN 'INFO'    THEN RAISE INFO    '%, %, %', user, clock_timestamp(), msg;
        WHEN 'WARNING' THEN RAISE WARNING '%, %, %', user, clock_timestamp(), msg;
        ELSE NULL; -- other values turn off the messages
    END CASE;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

For this example, set it just for the current session:

=> SET app.raise_level TO 'NONE';
SET

This way, the procedure will not post any debug messages during “production” (app.raise_level = NONE):

=> CALL long_running();
CALL

Starting the function in a separate session, we can get debug messages by setting the app.raise_level to NOTICE:

=> SET app.raise_level TO 'NOTICE';
SET
=> CALL long_running();
NOTICE:  student, 2025-11-27 14:25:05.09135+03, long_running. Stage 1/3...
NOTICE:  student, 2025-11-27 14:25:07.092756+03, long_running. Stage 2/3...
NOTICE:  student, 2025-11-27 14:25:10.09479+03, long_running. Stage 3/3...
NOTICE:  student, 2025-11-27 14:25:11.095751+03, long_running. Done
CALL

To enable both debugging in the application and logging, set the message level to LOG:

=> SET app.raise_level TO 'LOG';
SET
=> CALL long_running();
CALL

Check the server log:

student$ tail -n 30 /var/log/postgresql/postgresql-16-main.log | grep 'long_running\.'
2025-11-27 14:25:11.215 MSK [40142] student@plpgsql_debug LOG:  student, 2025-11-27
14:25:11.215472+03, long_running. Stage 1/3...
SQL statement "CALL debug_message('long_running. Stage 1/3...')"
2025-11-27 14:25:13.216 MSK [40142] student@plpgsql_debug LOG:  student, 2025-11-27
14:25:13.216795+03, long_running. Stage 2/3...
SQL statement "CALL debug_message('long_running. Stage 2/3...')"
2025-11-27 14:25:16.219 MSK [40142] student@plpgsql_debug LOG:  student, 2025-11-27
14:25:16.219773+03, long_running. Stage 3/3...
SQL statement "CALL debug_message('long_running. Stage 3/3...')"
2025-11-27 14:25:17.220 MSK [40142] student@plpgsql_debug LOG:  student, 2025-11-27
14:25:17.220785+03, long_running. Done
SQL statement "CALL debug_message('long_running. Done')"

By adjusting app.raise_level, log_min_messages, and client_min_messages, you can achieve different debug message output behavior. Very importantly, all this is done without tampering with the application code.

Page 11: Process→Process (IPC)

NOTIFY → LISTEN

  • SQL commands
  • transactional execution is inconvenient for debugging
  • Session status
    • the application_name parameter
    • is visible in the pg_stat_activity view and in the output of the ps command
    • can be used in log messages

Speaker notes

In PostgreSQL, server processes can communicate between each other.

Among the built-in solutions, the following are worth noting.

● Sending messages via the NOTIFY command in one process and getting them via LISTEN in another. But since these commands are transactional, it is inconvenient to use them for debugging: 1. Messages are sent only at commit time, not right after the NOTIFY command execution, so it is impossible to track the execution progress. 2. If the transaction fails, messages won’t be sent at all.

● Using the application_name parameter. A session with a long-running process can periodically write its execution status into the application_name parameter. In a separate session, a DBA can poll the pg_stat_activity view, which contains detailed information about all active sessions. The application_name value is usually also visible in the output of the ps command. The application_name value can also be written to the server log (if you set up the log_line_prefix parameter). As a result, relevant log entries will be easier to find.

https://postgrespro.com/docs/postgresql/16/runtime-config-logging#RUNTIM E-CONFIG-LOGGING-WHAT

Page 12: Session Status

application_name is a useful parameter for debugging. The first session changes its value, the second session checks the pg_stat_activity view in a loop.

=> \c plpgsql_debug
You are now connected to database "plpgsql_debug" as user "student".

New version of the procedure:

=> CREATE OR REPLACE PROCEDURE debug_message(msg text)
AS $$
BEGIN
PERFORM set_config('application_name', format('%s', msg), /* is_local */ true);
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

In the first session:

=> CALL long_running();

In the second session, read the value from pg_stat_activity every two seconds:

=> SELECT pid, usename, application_name
FROM pg_stat_activity
WHERE datname = 'plpgsql_debug' AND pid <> pg_backend_pid();
  pid  | usename |      application_name
-------+---------+----------------------------
 40142 | student | long_running. Stage 1/3...
(1 row)
=> \g
  pid  | usename |      application_name
-------+---------+----------------------------
 40142 | student | long_running. Stage 2/3...
(1 row)
=> \g
  pid  | usename |      application_name
-------+---------+----------------------------
 40142 | student | long_running. Stage 3/3...
(1 row)
=> \g
  pid  | usename | application_name
-------+---------+------------------
 40142 | student | psql
(1 row)
CALL

Page 13: Process→Table

The dblink extension

  • is part of the server
  • incurs additional costs for opening a new connection
  • Autonomous transactions
    • Postgres Pro Enterprise

Speaker notes

Another way to save debug messages is to write them into a database table.

One of the advantages of this approach is that log access and concurrent execution are managed by the database system itself.

But you have to make sure that insertion operations on this table are non-transactional. It can be done using the dblink extension, which is provided as part of the PostgreSQL server. This extension enables you to open another connection to the same database, so insertion is performed in a separate transaction.

As for the disadvantages, opening a new connection takes additional server resources.

We cover dblink usage in more detail in the DEV2 course.

https://postgrespro.com/docs/postgresql/16/dblink

Postgres Pro Enterprise implements autonomous transactions with lower overhead than dblink usage (See the PGPRO course about Postgres Pro Enterprise features).

Page 14: Writing to a Table: the dblink Extension

Install the extension:

=> CREATE EXTENSION dblink;
CREATE EXTENSION

Create a table to write into. It makes sense to store information about the user and the time of record. The id column is there to make sure that the table is sorted in the order in which the rows are added.

=> CREATE TABLE log (
    id       integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    username text,
    ts       timestamptz,
    message  text
);
CREATE TABLE

Now, rewrite the procedure to add records to the table. The procedure opens a new session, performs an insert in a separate transaction, and closes the session.

=> CREATE OR REPLACE PROCEDURE debug_message(msg text)
AS $$
DECLARE
    cmd text;
BEGIN
    cmd := format(
        'INSERT INTO log (username, ts, message)
         VALUES (%L, %L::timestamptz, %L)',
        user, clock_timestamp()::text, debug_message.msg
    );
    PERFORM dblink('dbname=' || current_database(), cmd);
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Let’s test it: run our long_running procedure in a separate transaction, then roll it back.

=> BEGIN;
BEGIN
=> CALL long_running();
CALL
=> ROLLBACK;
ROLLBACK

Check if the table has all the debug_message records. The timestamps in ts tell us how much time has passed between the calls.

=> SELECT username, to_char(ts, 'HH24:MI:SS') as ts, message
FROM log
ORDER BY id;
 username |    ts    |          message
----------+----------+----------------------------
 student  | 14:25:25 | long_running. Stage 1/3...
 student  | 14:25:27 | long_running. Stage 2/3...
 student  | 14:25:30 | long_running. Stage 3/3...
 student  | 14:25:31 | long_running. Done
(4 rows)

Page 15: Process→File

The adminpack extension

  • is part of the server
  • among other things, allows writing text files
  • Untrusted languages
    • for example, PL/Perl

Speaker notes

You can also write debug messages into an OS file.

It can be done using the adminpack extension, which allows writing data to any file that can be accessed by the postgres OS user.

Another option is to create a function in an untrusted language (such as PL/Perl — plperlu) that will perform the same task. Various server-side programming languages are covered in the DEV2 course.

https://postgrespro.com/docs/postgresql/16/adminpack

Page 16: Writing to a File: pg_file_write

Install the extension:

=> CREATE EXTENSION adminpack;
CREATE EXTENSION

Rewrite debug_message again, now making it write debug information to a file. The postgres OS user who started the DBMS instance must be allowed write to this file. Let’s place it in the user’s home directory.

=> CREATE OR REPLACE PROCEDURE debug_message(msg text)
AS $$
DECLARE
    filename CONSTANT text := '/var/lib/postgresql/log.txt';
    message text;
BEGIN
    message := format(E'%s, %s, %s\n',
        session_user, clock_timestamp()::text, debug_message.msg
    );
    PERFORM pg_file_write(filename, message, /* append */ true);
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

The function writes a separate line to the log file: the message passed by the parameter, the user responsible, and the timestamp. Test it: run long_running in a separate transaction, then roll it back.

=> BEGIN;
BEGIN
=> CALL long_running();
CALL
=> ROLLBACK;
ROLLBACK

Check the file contents. To access the file as student, you need to use sudo:

student$ sudo cat /var/lib/postgresql/log.txt
student, 2025-11-27 14:25:31.951922+03, long_running. Stage 1/3...
student, 2025-11-27 14:25:33.953793+03, long_running. Stage 2/3...
student, 2025-11-27 14:25:36.955905+03, long_running. Stage 3/3...
student, 2025-11-27 14:25:37.95683+03, long_running. Done

Page 17: Tracing SQL

Standard tracing into the log file

  • logging overhead
  • large log file
  • profiling tools are required
  • access to the log is required (security)
  • Settings
    • long-running statements
    • log_min_duration_statement
    • the statements to log
    • log_statement
    • message context
    • log_line_prefix
    • …

Speaker notes

In some cases, it may be useful to trace everything that happens in the code. Using the built-in functionality, you can save the executed SQL queries into the server log file. Make sure to take into account the following specifics:

● A high-load application can execute a huge number of queries. Writing them into a file can affect performance of the I/O subsystem.

● In most cases, you have to use special tools to analyze such data sets. A de-facto standard is pgBadger. https://github.com/darold/pgbadger

● Application developers may have no access to the log file on the server. Besides, in production systems, log files may contain commands with confidential information.

PostgreSQL provides several parameters to configure tracing; the main ones are listed on the slide. The full list is available here:

https://postgrespro.com/docs/postgresql/16/runtime-config-logging

You do not have to set configuration parameters for the whole cluster. Their scope can be limited to particular sessions using SET, ALTER DATABASE, and ALTER ROLE commands (as explained in Basic Tools. Installation and Management, psql and Data Organization. Logical Structure lessons).

Page 18: Tracing SQL

The auto\_explain extension

  • logging execution plans
  • tracing nested statements
  • Settings
    • plans of long-running commands
    • auto_explain.log_min_duration
    • nested statements
    • auto_explain.log_nested_statements
    • …

Speaker notes

When tracing is enabled, SQL commands make it into the log in their exact form that has been sent to the server. If a PL/pgSQL routine was called, the log will contain only this top-level call (for example, SELECT or CALL statement), but not the commands executed within the routine.

To log nested queries in addition to top-level commands, you have to use the auto_explain extension.

As suggested by its name, the main objective of this extension is to log both the text of the command and its execution plan. It can turn out to be useful, although it is not exactly tracing, but rather query optimization (which is covered in the QPT course).

https://postgrespro.com/docs/postgresql/16/auto-explain

Page 19: Tracing PL/pgSQL

The plpgsql\_check extension

  • overhead incurred by logging
  • loads of output data
  • The main settings
    • enabling tracing
    • plpgsql_check.enable_tracer
    • plpgsql_check.tracer
    • message levels
    • plpgsql_check.tracer_errlevel

Speaker notes

To figure out which code has been executed as you are looking at the log, you have to match SQL queries with PL/pgSQL routines, and it may be not that easy. There are no built-in tools for tracing PL/pgSQL code, but you can do it with the help of the plpgsql_check extension developed by Pavel Stehule (we have already mentioned this extension in PL/pgSQL. Executing Queries lesson).

Such tracing causes significant overhead and should only be used for debugging, not in production operations.

https://github.com/okbob/plpgsql\_check

Page 20: Session Tracing

One simple way to enable tracing is to set the log_statement parameter to ’all’ (write all commands, including DDL, data modification, and queries).

=> SET log_statement = 'all';
SET

Run any query:

=> SELECT get_count('pg_views');
NOTICE:  cmd: SELECT COUNT(*) FROM pg_views
 get_count
-----------
       141
(1 row)

Disable tracing:

=> RESET log_statement;
RESET

Information about the executed commands will appear in the server log:

student$ tail -n 2 /var/log/postgresql/postgresql-16-main.log
2025-11-27 14:25:38.285 MSK [40142] student@plpgsql_debug LOG:  statement: SELECT
get_count('pg_views');
2025-11-27 14:25:38.418 MSK [40142] student@plpgsql_debug LOG:  statement: RESET
log_statement;

Only the top level command is logged, not the query from within get_count. This is where the auto_explain extension comes in. This extension does not need to be installed, but must be loaded into memory. You can either set it up to load for the whole instance with shared_preload_libraries parameter, or just load it for the current session:

=> LOAD 'auto_explain';
LOAD

Enable tracing for all commands, regardless of execution time.

=> SET auto_explain.log_min_duration = 0;
SET

Enable tracing for nested queries:

=> SET auto_explain.log_nested_statements = on;
SET

The messages are raised the same way as with the RAISE statement. The LOG level is used by default, usually implying that the messages are recorded to the server log. You can change the log level to output tracing messages directly to the screen:

=> SET auto_explain.log_level = 'NOTICE';
SET

Try the query again:

=> SELECT get_count('pg_views');

Page 21: Page 21

NOTICE:  cmd: SELECT COUNT(*) FROM pg_views
NOTICE:  duration: 0.090 ms  plan:
Query Text: SELECT COUNT(*) FROM pg_views
Aggregate  (cost=19.52..19.53 rows=1 width=8)
  ->  Seq Scan on pg_class c  (cost=0.00..19.16 rows=141 width=0)
        Filter: (relkind = 'v'::"char")
NOTICE:  duration: 1.244 ms  plan:
Query Text: SELECT get_count('pg_views');
Result  (cost=0.00..0.26 rows=1 width=8)
 get_count
-----------
       141
(1 row)

Now we see not only the function call, but also the nested query and the execution plans.

Page 22: Takeaways

PL/pgSQL Debugger is a debugger API used in graphical development environments

  • Debugging output can be displayed in the terminal, written into the server log, a table, or a file; it can also be sent to other processes
  • Sessions can be traced

Page 23: Practice

  1. Modify the get_catalog function, so that the dynamically constructed text of the query is written into the server log.
  • In the application, perform search several times by filling out different fields; make sure that SQL commands are constructed correctly. 2. Enable tracing of SQL statements at the server level.
    • Perform some actions in the application and check which
    • commands are logged.
    • Disable tracing.

Speaker notes

  1. To enable tracing, set the log_min_duration_statement parameter to 0 and reload the configuration. All commands will be logged, together with their execution time.

The easiest way to do it is to use the ALTER SYSTEM SET command. Other ways of setting parameters are covered in Basic Tools. Installation and Management, psql lesson. Remember to reload the server configuration file.

After having a look at the log file, you should reset log_min_duration_statement to the default value (-1) to disable tracing. It is convenient to use ALTER SYSTEM RESET for this purpose.

Page 24: 1. The get_catalog Function

The text of a dynamic query is stored in a separate variable, which is saved to the server log before executing. Include parameter values in the message for clarity. Debug strings are marked with the “DEBUG get_catalog” substring in the log. After debugging, the RAISE LOG command can be removed or commented out.

=> CREATE OR REPLACE FUNCTION get_catalog(
    author_name text,
    book_title text,
    in_stock boolean
)
RETURNS TABLE(book_id integer, display_name text, onhand_qty integer)
AS $$
DECLARE
    title_cond text := '';
    author_cond text := '';
    qty_cond text := '';
    cmd text := '';
BEGIN
    IF book_title != '' THEN
        title_cond := format(
            ' AND cv.title ILIKE %L', '%'||book_title||'%'
        );
    END IF;
    IF author_name != '' THEN
        author_cond := format(
            ' AND cv.authors ILIKE %L', '%'||author_name||'%'
        );
    END IF;
    IF in_stock THEN
        qty_cond := ' AND cv.onhand_qty > 0';
    END IF;
    cmd := '
        SELECT cv.book_id,
               cv.display_name,
               cv.onhand_qty
        FROM   catalog_v cv
        WHERE  true'
        || title_cond || author_cond || qty_cond || '
        ORDER BY display_name';
    RAISE LOG 'DEBUG get_catalog (%, %, %): %',
        author_name, book_title, in_stock, cmd;
    RETURN QUERY EXECUTE cmd;
END
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

2. Enabling and Disabling SQL Query Tracing

To enable tracing of all queries at the server level, run:

=> ALTER SYSTEM SET log_min_duration_statement = 0;
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

To disable:

=> ALTER SYSTEM RESET log_min_duration_statement;
ALTER SYSTEM
=> SELECT pg_reload_conf();

Page 25: Page 25

 pg_reload_conf
----------------
 t
(1 row)

The last two commands got into the message log:

student$ tail -n 6 /var/log/postgresql/postgresql-16-main.log
2025-11-27 14:30:45.656 MSK [72018] LOG:  received SIGHUP, reloading configuration files
2025-11-27 14:30:45.657 MSK [72018] LOG:  parameter "log_min_duration_statement" changed
to "0"
2025-11-27 14:30:45.791 MSK [75185] student@bookstore LOG:  duration: 9.576 ms
statement: ALTER SYSTEM RESET log_min_duration_statement;
2025-11-27 14:30:45.836 MSK [75185] student@bookstore LOG:  duration: 0.099 ms
statement: SELECT pg_reload_conf();
2025-11-27 14:30:45.837 MSK [72018] LOG:  received SIGHUP, reloading configuration files
2025-11-27 14:30:45.838 MSK [72018] LOG:  parameter "log_min_duration_statement" removed
from configuration file, reset to default

Page 26: Practice+

1. Enable tracing of the PL/pgSQL code using the plpgsql\_check extension; check how it works on the example of several routines that call each other. 2. When getting debug messages from the PL/pgSQL code, it is convenient to know the exact routine they are related to. In the demo, the function name was entered manually. Implement the functionality that automatically adds the name of the current function or procedure to the message.

Speaker notes

  1. To enable tracing, load the plpgsql_check extension into the session memory using the LOAD command, and then set both the plpgsql_check.enable_tracer and plpgsql_check.tracer parameters to on at the session level.

  2. You can get the routine name by parsing the call stack. Use the results of Task 3 that you have completed as part of the practice for the Error Handling lesson.

Page 27: 1. Tracing with plpgsql_check

=> CREATE DATABASE plpgsql_debug;
CREATE DATABASE
=> \c plpgsql_debug
You are now connected to database "plpgsql_debug" as user "student".

Download the extension (no need to install it with the CREATE EXTENSION command in this case):

=> LOAD 'plpgsql_check';
LOAD

Enabling tracing:

=> SET plpgsql_check.enable_tracer = on;
SET
=> SET plpgsql_check.tracer = on;
SET

Multiple functions calling each other:

=> CREATE FUNCTION foo(n integer) RETURNS integer
AS $$
BEGIN
    RETURN bar(n-1);
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE FUNCTION bar(n integer) RETURNS integer
AS $$
BEGIN
    RETURN baz(n-1);
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE FUNCTION baz(n integer) RETURNS integer
AS $$
BEGIN
    RETURN n;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

Example:

=> SELECT foo(3);
NOTICE:  #0   ->> start of function foo(integer) (oid=16387, tnl=1)
NOTICE:  #0       "n" => '3'
NOTICE:  #1     ->> start of function bar(integer) (oid=16388, tnl=1)
NOTICE:  #1         context: PL/pgSQL function foo(integer) line 3 at RETURN
NOTICE:  #1         "n" => '2'
NOTICE:  #2       ->> start of function baz(integer) (oid=16389, tnl=1)
NOTICE:  #2           context: PL/pgSQL function bar(integer) line 3 at RETURN
NOTICE:  #2           "n" => '1'
NOTICE:  #2       <<- end of function baz (elapsed time=0.030 ms)
NOTICE:  #1     <<- end of function bar (elapsed time=0.159 ms)
NOTICE:  #0   <<- end of function foo (elapsed time=0.392 ms)
 foo
-----
   1
(1 row)

Not only the start and end events of the functions are displayed, but also the parameter values, as well as the time spent on execution (the extension can also do profiling, but we will not get into that here). Disable tracing:

Page 28: 2. Function Names in the Debug Log

=> SET plpgsql_check.tracer = off;
SET

Create a procedure that returns the top of the call stack (with the exception of the tracing procedure itself). The message is displayed with indentation proportional to the depth of the stack.

=> CREATE PROCEDURE raise_msg(msg text)
AS $$
DECLARE
    ctx text;
    stack text[];
BEGIN
    GET DIAGNOSTICS ctx := PG_CONTEXT;
    stack := regexp_split_to_array(ctx, E'\n');
    RAISE NOTICE '%: %',
        repeat('. ', array_length(stack,1)-2) || stack[3], msg;
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Example:

=> CREATE TABLE t(n integer);
CREATE TABLE
=> CREATE FUNCTION on_insert() RETURNS trigger
AS $$
BEGIN
    CALL raise_msg('NEW = '||NEW::text);
    RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE TRIGGER t_before_row
BEFORE INSERT ON t
FOR EACH ROW
EXECUTE FUNCTION on_insert();
CREATE TRIGGER
=> CREATE PROCEDURE insert_into_t()
AS $$
BEGIN
    CALL raise_msg('start');
    INSERT INTO t SELECT id FROM generate_series(1,3) id;
    CALL raise_msg('end');
END
$$ LANGUAGE plpgsql;
CREATE PROCEDURE
=> CALL insert_into_t();
NOTICE:  . PL/pgSQL function insert_into_t() line 3 at CALL: start
NOTICE:  . . . PL/pgSQL function on_insert() line 3 at CALL: NEW = (1)
NOTICE:  . . . PL/pgSQL function on_insert() line 3 at CALL: NEW = (2)
NOTICE:  . . . PL/pgSQL function on_insert() line 3 at CALL: NEW = (3)
NOTICE:  . PL/pgSQL function insert_into_t() line 5 at CALL: end
CALL
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE plpgsql_debug;
DROP DATABASE

19 Lesson 19: Access Control / Overview

Native Markdown source page: Access Control / Overview
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_19_access_overview.html Archive: DEV1-handouts-16-en.zip PDF pages: 30

Page 1: Access Control / Overview

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo: Oleg Bartunov (Phu Monastery and Bhrikuti Peak, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Roles and Attributes
  • Connecting to a Server
  • Password Authentication
  • Privileges and Privilege Management
  • Role Categories
  • Group and Predefined Roles
  • Default Privileges
  • Privileges and Routines

Page 3: Roles and Attributes

Role can be a DBMS user

  • not associated with the OS user
  • Roles can be included into other roles
    • simplifies access setup
  • Attributes define the properties of a role
    • LOGIN
    • can log in
    • SUPERUSER
    • superuser privileges
    • CREATEDB
    • can create databases
    • CREATEROLE
    • can create roles
    • and others

Speaker notes

Roles in PostgreSQL are used for two purposes. Firstly, a role can be a DBMS user. Secondly, roles can be members of other roles — it is convenient when setting up access.

Formally, roles are not associated with operating system users in any way, but many programs imply it when choosing default values. For example, if psql is started on behalf of the student OS user, the connection is established on behalf of the database role with the same name, i.e., student (unless another role is explicitly specified in the psql options).

At the time of cluster initialization, an initial role is defined, which has superuser privileges (this role is usually called postgres). Later on, you can create, modify, and delete roles.

https://postgrespro.com/docs/postgresql/16/database-roles

A role has several attributes which define its general properties and rights (unrelated to particular objects).

Generally, attributes come in two opposite variations; for example, CREATEDB (can create databases) and NOCREATEDB (not allowed to create databases).

A role with the LOGIN attribute is considered a user role. A role with NOLOGIN cannot connect to the server and is typically used for grouping other roles.

The table lists only some of the possible attributes.

https://postgrespro.com/docs/postgresql/16/role-attributes

https://postgrespro.com/docs/postgresql/16/sql-createrole

Page 4: Roles and Attributes

Create a role for the user Alice. Two attributes are specified.

Note

Note Note which role the commands are executed as. The name of the current role is in the prompt.

student=# CREATE ROLE alice LOGIN PASSWORD 'alice';
CREATE ROLE

You can get the list of roles with the command:

student=# \du
                             List of roles
 Role name |                         Attributes
-----------+------------------------------------------------------------
 alice     |
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS
 student   | Superuser
Note

Note Note that the student role is a superuser. This is why we did not have any access issues so far. Create a database:

student=# CREATE DATABASE access_overview;
CREATE DATABASE
student=# \c access_overview
You are now connected to database "access_overview" as user "student".

Page 5: Connecting to a Server

1. The lines of pg\_hba.conf are searched from top to bottom

    1. The first line that corresponds to the provided connection settings (type, database, user, address) will be used
# TYPE        DATABASE     USER             ADDRESS           METHOD
local         all          postgres                           peer
local         all          all                                peer
host          all          all              127.0.0.1/32      scram-sha-256
host          all          all              ::1/128           scram-sha-256
  • local — socket all — any role host — TCP/IP role name
    • listen_addresses
    • all — any DB
    • all — any IP
    • database name
    • IP/mask
    • domain name

Speaker notes

For each new client, the server has to evaluate whether a database connection should be allowed. Connection settings are defined in the pg_hba.conf configuration file (hba stands for host-based authentication). As with the main configuration file (postgresql.conf), changes come into effect only after the server reloads this file, either via the pg_reload_conf() SQL function or the reload command of the management utility.

When a new client appears, the server reads the configuration file from top to bottom to find the line that matches the requested connection. The match is defined by four fields: connection type, database name, user name, and IP address.

Here we list only the main basic options.

Connection: local (unix sockets) or host (a TCP/IP connection).

Database: all (corresponds to any database) or the name of a particular database.

User: all or the name of a particular role.

Address: all, IP address range, or a domain name. The address is omitted for the local connection type. By default, PostgreSQL listens only for connections coming from localhost; the listen_addresses parameter is usually set to * (listen on all interfaces), while the access is controlled using pg_hba.conf settings.

https://postgrespro.com/docs/postgresql/16/client-authentication

Page 6: Connecting to a Server

3. The server performs authentication using the chosen method

    1. If successful, access is allowed; otherwise, it is forbidden (if no rows match the given parameters, access is forbidden)
# TYPE        DATABASE     USER             ADDRESS           METHOD
local         all          postgres                           peer
local         all          all                                peer
host          all          all              127.0.0.1/32      scram-sha-256
host          all          all              ::1/128           scram-sha-256
  • trust — allow
  • reject — forbid
  • scram-sha-256 и md5 — request a password
  • peer — ask OS

Speaker notes

Once the server finds an appropriate line in the file, it performs client authentication using the method specified in this line, and checks for the LOGIN attribute and the CONNECT privilege. If everything is OK, the connection is allowed; otherwise, it is forbidden (other lines won’t be considered in this case).

If no appropriate line is found, the access is also forbidden.

Thus, more specific connection lines should precede more generic ones.

There are a lot of different authentication methods:

https://postgrespro.com/docs/postgresql/16/auth-methods

Here are some of the main ones.

The trust method allows connections unconditionally. If security is not a concern, you can specify the trust method and use all for all the other parameters; then all connections will be allowed.

The reject method, on the contrary, unconditionally forbids connections.

The scram-sha-256 method asks for a password and checks that the provided password matches the one stored in the system catalog of the database cluster. The md5 method is considered deprecated.

The peer method checks the name of the operating system user and allows connections on behalf of the database user with the same name (you can also define a different name mapping).

Page 7: Password Authentication

At the server side

  • the password is set when the role is created and can be changed later
  • a user that has no password won’t be able to connect
  • the password is stored in the pg_authid system catalog
  • Entering the password on the client
    • manually
    • using the PGPASSWORD environment variable
    • using the ~/.pgpass file (lines format: node:port:database:role:password)

Speaker notes

If password authentication is used, there must be a reference password stored for the user; otherwise the connection will be rejected.

Password hashes are stored in the pg_authid table in the system catalog.

The user can either enter the password manually, or automate password input using one of the following options.

First, the password can be set in the PGPASSWORD environment variable (on the client). However, it is inconvenient if you have to connect to several databases, and it is not recommended for security reasons.

The second option to store passwords on the client is to use the ~/.pgpass file. The access to this file must be allowed to its owner only (chmod 600), otherwise PostgreSQL will ignore it.

Page 8: Connection

In order for a role to connect to the database, it must have both the LOGIN attribute and the permission in the file pg_hba.conf. You can usually find it right beside the main configuration file:

student=# SHOW hba_file;
              hba_file
-------------------------------------
 /etc/postgresql/16/main/pg_hba.conf
(1 row)

And you can read it directly from SQL:

student=# SELECT type, database, user_name, address, auth_method
FROM pg_hba_file_rules();
 type  |   database    | user_name |  address  |  auth_method
-------+---------------+-----------+-----------+---------------
 local | {all}         | {all}     |           | trust
 host  | {all}         | {all}     | 127.0.0.1 | scram-sha-256
 host  | {all}         | {all}     | ::1       | scram-sha-256
 local | {replication} | {all}     |           | trust
 host  | {replication} | {all}     | 127.0.0.1 | scram-sha-256
 host  | {replication} | {all}     | ::1       | scram-sha-256
(6 rows)

(The contents may vary depending on the server build.) We will use a TCP/IP (host) connection to localhost. Such connection corresponds to the second string in the output, expecting password-based authentication. The role alice was created with a password right away, but the password can be changed at any time:

student=# ALTER ROLE alice PASSWORD 'alicepass';
ALTER ROLE

Attempt to connect using a connection string with all the info:

student$ psql 'host=localhost user=alice dbname=access_overview password=alicepass'
alice=> \conninfo
You are connected to database "access_overview" as user "alice" on host "localhost"
(address "127.0.0.1") at port "5432".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)

Success!

Page 9: Privileges

Privileges define access rights of roles to objects

  • Tables and views
    • SELECT
    • read data
    • INSERT
    • insert rows
    • can be set per column
    • UPDATE
    • change rows
REFERENCES
 foreign key (for tables)
  • DELETE
  • delete rows
TRUNCATE
 empty (for tables)
TRIGGER
 create triggers

Speaker notes

Privileges establish a relation between subjects (roles) and objects in the cluster. They determine the actions that roles can perform with these objects.

There are different privileges available for different object types. This slide and the following one list privileges for basic database objects.

The widest choice of privileges is available for tables and views. Some of these privileges can be set not only at the table level, but also at the column level.

https://postgrespro.com/docs/postgresql/16/ddl-priv

https://postgrespro.com/docs/postgresql/16/sql-grant

Page 10: Privileges

Source slide page 10: Privileges{#source-figure-010 caption=“Source slide page 10: Privileges”}

Tablespaces, database, schemas

  • Sequences
    • SELECT
    • currval
    • UPDATE
    • nextval
    • setval
    • USAGE
    • currval
    • nextval

Speaker notes

Sequences have a somewhat unexpected set of privileges. They serve to allow or restrict access to the three control functions.

For tablespaces, there is a CREATE privilege that allows the creation of objects in this tablespace.

For databases, the CREATE privilege allows you to create schemas in this database, and for a schema, the CREATE privilege allows you to create objects in this schema.

Since the exact name of the schema for temporary objects is unknown in advance, the privilege to create temporary tables has been moved to the database level (TEMPORARY).

The USAGE schema privilege allows access to objects in this schema.

The CONNECT database privilege allows connection to this database.

Page 11: Role Categories

Superusers

  • full access to all objects, no checks performed
  • Owners
    • initially, all privileges on the object (can be revoked)
    • actions that are not regulated by privileges, such as deleting objects, granting
    • and revoking privileges, etc.
  • Other roles
    • access within the granted privileges

Speaker notes

Generally speaking, a role’s ability to access an object is defined by the role’s privileges. But it makes sense to single out three categories of roles and discuss them separately.

  1. Roles with the SUPERUSER attribute (superusers). These roles can do anything and ignore all access control checks.

  2. Object owner. Initially, this is the role that created the object, although it can be changed later. It’s not just the object creator role that becomes the owner, but also any other role included in it. The object owner gets the full range of privileges on this object.

Technically, these privileges can be revoked, but the owner always retains inherent rights on the actions that are not regulated by any privileges. In particular, the owner can grant and revoke privileges (including to and from themselves), delete the object, etc.

  1. All other roles have access to the object only as far as the privileges granted to them allow it.

To check if a role has the necessary privilege with respect to some object, you can use the has_*_privilege functions:

https://postgrespro.com/docs/postgresql/16/functions-info

Page 12: Privilege Management

Granting privileges

alice: GRANT privileges ON object TO bob;
  • alice
  • bob
  • privileges
  • on object
  • Revoking privileges
alice: REVOKE privileges ON object FROM bob;

Speaker notes

The right to grant and revoke privileges on an object belongs to the owner of that object (and the superuser).

The syntax of the GRANT and REVOKE commands is quite complex. You can specify both individual and all possible privileges, both individual objects and groups of objects included in certain schemas, etc.

https://postgrespro.com/docs/postgresql/16/sql-grant

https://postgrespro.com/docs/postgresql/16/sql-revoke

Page 13: Privileges

Source slide page 13: Privileges{#source-figure-013 caption=“Source slide page 13: Privileges”}

Alice has connected to the database. Now she wants to create a schema and some objects.

alice=> CREATE SCHEMA alice;
ERROR:  permission denied for database access_overview

What’s the issue? Alice does not have the privilege to create schemas in the database. Grant it:

student=# GRANT CREATE ON DATABASE access_overview TO alice;
GRANT

Try again:

alice=> CREATE SCHEMA alice;
CREATE SCHEMA

Now, since Alice is the owner of the schema, she has all the privileges on it and can create any objects in it. This schema will be used by default:

alice=> SELECT current_schemas(false);
 current_schemas
-----------------
 {alice,public}
(1 row)

Alice creates two tables.

alice=> CREATE TABLE t1(n numeric);
CREATE TABLE
alice=> INSERT INTO t1 VALUES (1);
INSERT 0 1
alice=> CREATE TABLE t2(n numeric, who text DEFAULT current_user);
CREATE TABLE
alice=> INSERT INTO t2(n) VALUES (1);
INSERT 0 1

The superuser creates another role for the user Bob, who will access objects belonging to Alice.

student=# CREATE ROLE bob LOGIN PASSWORD 'bobpass';
CREATE ROLE
student$ psql 'host=localhost user=bob dbname=access_overview password=bobpass'

Bob attempts to access the table t1:

bob=> SELECT * FROM alice.t1;
ERROR:  permission denied for schema alice
LINE 1: SELECT * FROM alice.t1;
                      ^

What happened? Bob is neither a superuser nor the owner of this schema, so access is denied. This command lists current access rights (Access privileges column):

alice=> \x \dn+ \x

Page 14: Page 14

Source slide page 14: Page 14{#source-figure-014 caption=“Source slide page 14: Page 14”}

Expanded display is on.
List of schemas
-[ RECORD 1 ]-----+---------------------------------------
Name              | alice
Owner             | alice
Access privileges |
Description       |
-[ RECORD 2 ]-----+---------------------------------------
Name              | public
Owner             | pg_database_owner
Access privileges | pg_database_owner=UC/pg_database_owner+
                  | =U/pg_database_owner
Description       | standard public schema
Expanded display is off.

Privileges are displayed in the format: role=privileges/granted_by. Each privilege is encoded with a single character. In particular, for schemas: U = usage; C = create. If the role name is omitted (as in the last line), the pseudorole public is implied. Note that the public pseudorole has only USAGE privilege on the public schema. Here, pg_database_owner is the owner of the database. If the entire field is omitted (as in the first line), then the default privileges are implied: Alice gets both available privileges on her schema, and the other roles do not get any. Grant Bob access to the schema. Alice can do that as the owner.

alice=> GRANT CREATE, USAGE ON SCHEMA alice TO bob;
GRANT

Bob attempts to access the table t1 again:

bob=> SELECT * FROM alice.t1;
ERROR:  permission denied for table t1

Another error. What happened now? This time, Bob has access to the schema, but not to the table itself. This command shows who has access to the table:

alice=> \dp alice.t1
                            Access privileges
 Schema | Name | Type  | Access privileges | Column privileges | Policies
--------+------+-------+-------------------+-------------------+----------
 alice  | t1   | table |                   |                   |
(1 row)

The field is empty: only the owner, Alice, has access. Alice grants Bob read and update access:

alice=> GRANT SELECT, UPDATE ON alice.t1 TO bob;
GRANT

And read and insert rights for one column in the second table:

alice=> GRANT SELECT(n), INSERT ON alice.t2 TO bob;
GRANT

The privileges have changed:

alice=> \dp alice.*
                             Access privileges
 Schema | Name | Type  |  Access privileges  | Column privileges | Policies
--------+------+-------+---------------------+-------------------+----------
 alice  | t1   | table | alice=arwdDxt/alice+|                   |
        |      |       | bob=rw/alice        |                   |
 alice  | t2   | table | alice=arwdDxt/alice+| n:               +|
        |      |       | bob=a/alice         |   bob=r/alice     |
(2 rows)

Now the empty field has “appeared” and contains a complete list of privileges. Below are the designations, not all of them are quite

Page 15: Page 15

Source slide page 15: Page 15{#source-figure-015 caption=“Source slide page 15: Page 15”}

obvious: a = insert r = select w = update d = delete D = truncate x = reference t = trigger Privileges for columns are displayed separately (under Column privileges). This time Bob is successful. He adds the schema name to his search path, so that he does not have to type it in every time.

bob=> ALTER ROLE bob SET search_path = public, alice;
ALTER ROLE

Now, the search path will be set in each of Bob’s sessions.

bob=> \c
You are now connected to database "access_overview" as user "bob".
bob=> SHOW search_path;
  search_path
---------------
 public, alice
(1 row)
bob=> UPDATE t1 SET n = n + 1;
UPDATE 1
bob=> SELECT * FROM t1;
 n
---
 2
(1 row)

However, all other operations remain restricted.

bob=> DELETE FROM t1;
ERROR:  permission denied for table t1

And the second table:

bob=> INSERT INTO t2(n) VALUES (100);
INSERT 0 1
bob=> SELECT n FROM t2;
  n
-----
   1
 100
(2 rows)

Reading of the other column is not permitted:

bob=> SELECT * FROM t2;
ERROR:  permission denied for table t2

Page 16: Granting Membership

Source slide page 16: Granting Membership{#source-figure-016 caption=“Source slide page 16: Granting Membership”}

public Granting membership

alice: GRANT dba TO bob;
  • alice
  • bob
  • dba
  • public pseudo-role implicitly includes
  • all other roles
  • Revoking membership
alice: REVOKE dba FROM bob;

Speaker notes

Any role can include other roles as members. In this case, the role acts as a group. PostgreSQL does not have a separate “group” entity.

A role can be a member of multiple roles; in turn, a member role may include other roles, but circular dependencies are not permitted.

By default, a role inherits the privileges of any group roles it is a member of. This behavior can be changed by using the NOINHERIT attribute for the role. This makes the user have to explicitly switch to the group role using SET ROLE in order to use its privileges. Role attributes are not inherited, but it is possible to switch to a parent role to use its attributes.

Roles that include other roles typically have the NOLOGIN attribute and are referred to as “group roles”. We can think of a group role as a predefined set of privileges that can be granted to a role just like any regular privilege. This simplifies access control and administration.

There is also a pseudorole called public, which implicitly includes all the other roles. Any privilege granted to the public role is automatically granted to all the other roles as well.

https://postgrespro.com/docs/postgresql/16/role-membership

Page 17: Predefined Roles

pg_monitor

  • pg_read_all_settings read all server parameters
  • pg_read_all_stats access statistics pg_stat_scan_tables monitoring and locking tables
  • pg_read_all_data read data in all tables pg_write_all_data change data in all tables
  • pg_read_server_files read files on the server pg_write_server_files write to files on the server
  • pg_execute_server_programs run programs on the server
  • …

Speaker notes

PostgreSQL provides a number of predefined roles that have access to some frequently used, but privileged capabilities and data. Membership in these roles may be granted to users in order to facilitate administrative and maintenance tasks without providing them superuser capabilities.

The list of predefined roles increases with each PostgreSQL version. The full list of all the roles, including predefined ones, cat be seen by \duS command in psql.

https://postgrespro.com/docs/postgresql/16/predefined-roles

You can create your own administrative group roles, e.g. for managing backups.

Page 18: Group Roles

Source slide page 18: Group Roles{#source-figure-018 caption=“Source slide page 18: Group Roles”}

Bob could not read the second column:

bob=> SELECT * FROM t2;
ERROR:  permission denied for table t2

The superuser includes Bob into the predefined group role pg_read_all_data:

student=# GRANT pg_read_all_data TO bob;
GRANT ROLE

Now Bob can read all tables as if he had been granted SELECT privileges for all tables and USAGE privileges for all schemas:

bob=> SELECT * FROM t2;
  n  |  who
-----+-------
   1 | alice
 100 | bob
(2 rows)

You can use the command \drg in psql to get role membership information:

student=# \drg
                  List of role grants
 Role name |    Member of     |   Options    | Grantor
-----------+------------------+--------------+----------
 bob       | pg_read_all_data | INHERIT, SET | postgres
(1 row)

The Options column shows attributes for the membership. For Bob as a member of pg_read_all_data, SET means the right to switch to the group role, and INHERIT means that Bob can use the group privileges without explicitly switching to the group. Exclude Bob from the group:

student=# REVOKE pg_read_all_data FROM bob;
REVOKE ROLE

Page 19: Routines

The only privilege for functions and procedures

  • EXECUTE
  • execution
  • Security modes
    • SECURITY INVOKER
    • executed with the privileges of the caller
    • (by default)
    • SECURITY DEFINER
    • executed with the privileges of the owner

Speaker notes

The privilege EXECUTE, the only privilege for functions and procedures, allows them to execute.

The user on behalf of which the routine is executed is important. If a routine is declared as a SECURITY INVOKER (by default), it is executed with the rights of the user that runs it. In this case, the statements inside the routine can access only those objects that are accessible to the calling user.

On the other hand, if declared with the SECURITY DEFINER, the routine will use the rights of its owner. This is a way to allow certain users perform certain actions on objects they personally have no access to.

https://postgrespro.com/docs/postgresql/16/sql-createfunction

https://postgrespro.com/docs/postgresql/16/sql-createprocedure

Page 20: Default Privileges

Privileges of the public pseudo-role

  • connect to any database
  • access to the system catalog
  • execution of any routines
  • privileges are granted automatically on each new object
  • Configurable default privileges
    • the possibility to additionally grant or revoke privileges on a newly created
    • object

Speaker notes

As we have already said, the public pseudo-role includes all other roles, so they inherit all the privileges granted to public.

And public has quite an extensive list of privileges by default. In particular:

● The right to connect to any database (that’s why the role alice could connect to the database although the CONNECT privilege had not been explicitly granted to this role)

● Access to the system catalog

● Execution of any routines

On the one hand, it enables seamless operation without having to deal with privileges; but on the other hand, it brings extra complications if access control is really required.

The public role automatically receives all the privileges listed above for all newly created objects. So it is not enough to simply revoke the EXECUTE privilege from public: once a new routine appears, public immediately gets the right to execute it.

There is a special mechanism of default privileges that enables you to automatically grant and revoke the required privileges on newly created objects. It can be also used to revoke the EXECUTE privilege from the public pseudo-role.

https://postgrespro.com/docs/postgresql/16/sql-alterdefaultprivileges

Page 21: Configurable Default Privileges

Source slide page 21: Configurable Default Privileges{#source-figure-021 caption=“Source slide page 21: Configurable Default Privileges”}

Alice creates a function:

alice=> CREATE FUNCTION foo() RETURNS SETOF t2
AS $$
SELECT * FROM t2;
$$ LANGUAGE sql STABLE;
CREATE FUNCTION

Can Bob execute the function without Alice granting him the EXECUTE privilege?

bob=> SELECT foo();
ERROR:  permission denied for table t2
CONTEXT:  SQL function "foo" statement 1

Bob can call the function, but still cannot access any objects for which he does not have the appropriate privileges. If Bob creates a table t2 in the schema public, the function will work for both users but will actually access different tables, since Alice and Bob have different search paths. In order for Bob to create a table in the schema public, he must have the CREATE privilege for the schema (in PostgreSQL 15+):

student=# GRANT CREATE ON SCHEMA public TO bob;
GRANT
bob=> CREATE TABLE t2(n numeric, who text DEFAULT current_user);
CREATE TABLE
bob=> INSERT INTO t2(n) VALUES (42);
INSERT 0 1
bob=> SELECT foo();
   foo
----------
 (42,bob)
(1 row)
alice=> SELECT foo();
    foo
-----------
 (1,alice)
 (100,bob)
(2 rows)

Alternatively, the function can be declared as using the privileges of its owner:

alice=> ALTER FUNCTION foo() SECURITY DEFINER;
ALTER FUNCTION

In this case, the function will always be executed with the privileges Alice has, regardless of who actually calls it. Bob deletes his table…

bob=> DROP TABLE t2;
DROP TABLE

…and gets access to the Alice’s table:

bob=> SELECT foo();
    foo
-----------
 (1,alice)
 (100,bob)
(2 rows)

In this situation, Alice needs to keep an eye on the granted privileges. For one, she should revoke the EXECUTE privilege from the role public and explicitly grant it only to the roles that should have it.

Page 22: Page 22

Source slide page 22: Page 22{#source-figure-022 caption=“Source slide page 22: Page 22”}

alice=> REVOKE EXECUTE ON ALL ROUTINES IN SCHEMA alice FROM public;
REVOKE
bob=> SELECT foo();
ERROR:  permission denied for function foo

To make matters worse, for each new function, the EXECUTE privilege is always granted to public by default. You can configure default privileges so that specific users would get (or lose) specific privileges on newly created objects:

alice=> ALTER DEFAULT PRIVILEGES
FOR ROLE alice
REVOKE EXECUTE ON ROUTINES FROM public;
ALTER DEFAULT PRIVILEGES
alice=> ALTER DEFAULT PRIVILEGES
FOR ROLE alice
GRANT EXECUTE ON ROUTINES TO bob;
ALTER DEFAULT PRIVILEGES
alice=> \ddp
           Default access privileges
 Owner | Schema |   Type   | Access privileges
-------+--------+----------+-------------------
 alice |        | function | alice=X/alice    +
       |        |          | bob=X/alice
(1 row)

Now, Bob is immediately granted execute privileges on routines created by Alice, while other users will not have permission to run them.

alice=> CREATE FUNCTION bar() RETURNS integer
LANGUAGE sql IMMUTABLE SECURITY DEFINER
RETURN 1;
CREATE FUNCTION
bob=> SELECT bar();
 bar
-----
   1
(1 row)

Page 23: Takeaways

Roles, attributes and privileges together form a flexible mechanism that allows you to set up access control in different ways

  • can grant all access widely
    • can restrict access heavily if necessary
  • When creating a new role, you need to ensure that it can connect to the server

Page 24: Practice

    1. Create two roles (the password must match the name): – employee — store employee, – buyer — customer.
  • Make sure that the created roles can connect to the database. 2. Revoke the rights of the role public to perform all functions and connect to the database. 3. Delimit access in such a way that: – the employee could only order books and add authors and books, – the buyer could only purchase books. Check the changes in the application.

Speaker notes

  1. The employee is an internal user of the application, authentication is performed at the database level.

The buyer is an external user. In a real online store, the management of such users falls to the application, and all queries come to the database from one “generalized” role (buyer). The identifier of a specific customer can be passed as a parameter (but we do not do this in our application).

  1. Generally speaking, access control should be embedded in the application. In our educational application, it is not implemented on purpose: instead, on the web page, you can explicitly select the role on behalf of which the query will be sent to the database. This allows you to see how the server side will behave if the application is not working correctly.

So, users need to get:

● The right to connect to the bookstore database and access the bookstore schema.

● Access to views that are accessed directly.

● Access to functions that are called as part of the API. If you leave the SECURITY INVOKER functions in, you will have to grant access to all the “underlying” objects (tables, other functions). However, it is more convenient to simply declare API functions as SECURITY DEFINER.

Of course, roles need to be granted privileges only on those objects that they should have access to.

Page 25: 1. Create roles

=> CREATE ROLE employee LOGIN PASSWORD 'employee';
CREATE ROLE
=> CREATE ROLE buyer LOGIN PASSWORD 'buyer';
CREATE ROLE

The default settings allow local connections by password. This works for us.

2. Privileges of “public”

Revoke extra privileges from the role “public”.

=> REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA bookstore FROM public;
REVOKE
=> REVOKE CONNECT ON DATABASE bookstore FROM public;
REVOKE

3. Access control

Functions executed with the creator’s privileges.

=> ALTER FUNCTION get_catalog(text,text,boolean) SECURITY DEFINER;
ALTER FUNCTION
=> ALTER FUNCTION update_catalog() SECURITY DEFINER;
ALTER FUNCTION
=> ALTER FUNCTION add_author(text,text,text) SECURITY DEFINER;
ALTER FUNCTION
=> ALTER FUNCTION add_book(text,integer[]) SECURITY DEFINER;
ALTER FUNCTION
=> ALTER FUNCTION buy_book(integer) SECURITY DEFINER;
ALTER FUNCTION
=> ALTER FUNCTION book_name(integer,text,integer) SECURITY DEFINER;
ALTER FUNCTION
=> ALTER FUNCTION authors(books) SECURITY DEFINER;
ALTER FUNCTION

Buyer privileges: the buyer can search for and purchase books.

=> GRANT CONNECT ON DATABASE bookstore TO buyer;
GRANT
=> GRANT USAGE ON SCHEMA bookstore TO buyer;
GRANT
=> GRANT EXECUTE ON FUNCTION get_catalog(text,text,boolean) TO buyer;
GRANT
=> GRANT EXECUTE ON FUNCTION buy_book(integer) TO buyer;
GRANT

Employee privileges: the employee must have access to view and add books and authors, as well as to the catalog for ordering more books.

=> GRANT CONNECT ON DATABASE bookstore TO employee;
GRANT

Page 26: Page 26

=> GRANT USAGE ON SCHEMA bookstore TO employee;
GRANT
=> GRANT SELECT,UPDATE(onhand_qty) ON catalog_v TO employee;
GRANT
=> GRANT SELECT ON authors_v TO employee;
GRANT
=> GRANT SELECT ON operations_v TO employee;
GRANT
=> GRANT EXECUTE ON FUNCTION book_name(integer,text,integer) TO employee;
GRANT
=> GRANT EXECUTE ON FUNCTION authors(books) TO employee;
GRANT
=> GRANT EXECUTE ON FUNCTION author_name(text,text,text) TO employee;
GRANT
=> GRANT EXECUTE ON FUNCTION add_book(text,integer[]) TO employee;
GRANT
=> GRANT EXECUTE ON FUNCTION add_author(text,text,text) TO employee;
GRANT

Page 27: Practice+

  1. Register the user roles alice and bob. 2. Modify the pg_hba.conf file to allow access without password only for the postgres and student users, ensuring that access for alice and bob remains restricted. 3. Enable peer authentication method for alice and bob. Check that connection attempts fail without OS user mapping. Create such mapping for alice. 4. Check the capability to use the same OS user mapping for different database roles.

Speaker notes

  1. Using a text editor, insert the new entry before the first uncommented directive in pg_hba.conf. local all postgres,student trust

Reload configuration.

  1. Modify the inserted directive in pg_hba.conf by replacing it with the following: local all postgres,student trust local all alice,bob peer

Append the following to the end of the pg_ident.conf file: stmap student alice

  1. For alice and bob roles to share a single mapping, the pg_ident.conf file shall be as follows: stmap student alice stmap student bob

Page 28: 1. Add Roles

Register roles with the permission to log in.

=> CREATE ROLE alice LOGIN;
CREATE ROLE
=> CREATE ROLE bob LOGIN;
CREATE ROLE

2. Limit the use of trust

Edit the contents of pg_hba.conf, allowing the trust method only for postgres and student.

student$ sudo sed -i 's/^local.*all.*all.*trust.*$/local all postgres,student trust\n/'
/etc/postgresql/16/main/pg_hba.conf

This is what we get:

=> SELECT type,database,user_name,address,auth_method,error
FROM pg_hba_file_rules
ORDER BY rule_number;
 type  |   database    |     user_name      |  address  |  auth_method  | error
-------+---------------+--------------------+-----------+---------------+-------
 local | {all}         | {postgres,student} |           | trust         |
 host  | {all}         | {all}              | 127.0.0.1 | scram-sha-256 |
 host  | {all}         | {all}              | ::1       | scram-sha-256 |
 local | {replication} | {all}              |           | trust         |
 host  | {replication} | {all}              | 127.0.0.1 | scram-sha-256 |
 host  | {replication} | {all}              | ::1       | scram-sha-256 |
(6 rows)

Reload the configuration.

=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Now, neither alice nor bob can connect.

student$ psql -l -U alice
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL:  no pg_hba.conf entry for host "[local]", user "alice", database "postgres", no
encryption
student$ psql -l -U bob
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL:  no pg_hba.conf entry for host "[local]", user "bob", database "postgres", no
encryption

3. Peer Authentication Method

Using a text editor, add another line with the peer authentication method to allow alice and bob to connect.

student$ sudo sed -i '/^local.*all.*postgres,student.*$/alocal all alice,bob peer'
/etc/postgresql/16/main/pg_hba.conf

Contents of pg_hba.conf:

=> SELECT type,database,user_name,address,auth_method,error
FROM pg_hba_file_rules
ORDER BY rule_number;

Page 29: Page 29

 type  |   database    |     user_name      |  address  |  auth_method  | error
-------+---------------+--------------------+-----------+---------------+-------
 local | {all}         | {postgres,student} |           | trust         |
 local | {all}         | {alice,bob}        |           | peer          |
 host  | {all}         | {all}              | 127.0.0.1 | scram-sha-256 |
 host  | {all}         | {all}              | ::1       | scram-sha-256 |
 local | {replication} | {all}              |           | trust         |
 host  | {replication} | {all}              | 127.0.0.1 | scram-sha-256 |
 host  | {replication} | {all}              | ::1       | scram-sha-256 |
(7 rows)

Reload the configuration.

=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

The users still cannot log in, but the error message is now different.

student$ psql -l -U alice
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL:  Peer authentication failed for user "alice"
student$ psql -l -U bob
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL:  Peer authentication failed for user "bob"

The peer authentication method requires the user names in the OS and roles in PostgreSQL to match. Map the role alice to the OS user student by adding a line to pg_ident.conf. Do not set up the mapping for bob, though.

student$ echo 'stmap student alice' | sudo tee -a /etc/postgresql/16/main/pg_ident.conf
stmap student alice

Add the parameter map to the line to set the mapping.

student$ sudo sed -i 's/peer.*$/peer map=stmap/' /etc/postgresql/16/main/pg_hba.conf

Contents of pg_hba.conf:

=> SELECT type,database,user_name,address,auth_method,options,error
FROM pg_hba_file_rules
ORDER BY rule_number;
 type  |   database    |     user_name      |  address  |  auth_method  |   options   |
error
-------+---------------+--------------------+-----------+---------------+-------------+---
----
 local | {all}         | {postgres,student} |           | trust         |             |
 local | {all}         | {alice,bob}        |           | peer          | {map=stmap} |
 host  | {all}         | {all}              | 127.0.0.1 | scram-sha-256 |             |
 host  | {all}         | {all}              | ::1       | scram-sha-256 |             |
 local | {replication} | {all}              |           | trust         |             |
 host  | {replication} | {all}              | 127.0.0.1 | scram-sha-256 |             |
 host  | {replication} | {all}              | ::1       | scram-sha-256 |             |
(7 rows)

Reload the configuration.

=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Now alice can connect to the database and execute commands.

student$ psql -c '\conninfo' -U alice -d student
You are connected to database "student" as user "alice" via socket in
"/var/run/postgresql" at port "5432".

And bob cannot.

student$ psql -c '\conninfo' -U bob -d student

Page 30: 4. One Mapping for Multiple Roles

psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL:  Peer authentication failed for user "bob"

Allow bob to connect under the same conditions as alice.

student$ echo 'stmap student bob' | sudo tee -a /etc/postgresql/16/main/pg_ident.conf
stmap student bob

Lines added to pg_ident.conf:

student$ sudo tail -n2 /etc/postgresql/16/main/pg_ident.conf
stmap student alice
stmap student bob

Reload the configuration.

=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Now both alice and bob can connect.

student$ psql -c '\conninfo' -U alice -d student
You are connected to database "student" as user "alice" via socket in
"/var/run/postgresql" at port "5432".
student$ psql -c '\conninfo' -U bob -d student
You are connected to database "student" as user "bob" via socket in "/var/run/postgresql"
at port "5432".

20 Lesson 20: Backup / Logical Backup

Native Markdown source page: Backup / Logical Backup
Note

This page is extracted from the official PDF into native Markdown. Slide text, code and speaker notes are kept in reading order; the original PDF and HTML remain the layout references. HTML source: dev1_20_backup_logical.html Archive: DEV1-handouts-16-en.zip PDF pages: 24

Page 1: Backup / Logical Backup

© Postgres Professional, 2017–2025 Authors: Egor Rogov, Pavel Luzanov, Ilya Bashtanov, Igor Gnatyuk Translated by: Liudmila Mantrova, Alexander Meleshko, Elena Sharafutdinova Photo by: Oleg Bartunov (Phu monastery, Bhrikuti summit, Nepal)

Use of Course Materials

Non-commercial use of course materials (presentations, demonstrations) is allowed without restrictions. Commercial use is possible only with the written permission of Postgres Professional. It is prohibited to make changes to the course materials.

Feedback

Please send your feedback, comments and suggestions to: edu@postgrespro.ru

Disclaimer

Postgres Professional assumes no responsibility for any damages and losses, including loss of income, caused by direct or indirect, intentional or accidental use of course materials. Postgres Professional company specifically disclaims any warranties on course materials. Course materials are provided “as is,” and Postgres Professional company has no obligations to provide maintenance, support, updates, enhancements, or modifications.

Page 2: Topics

  • Logical and Physical Backup
  • Backup and Restore of a Table
  • Backup and Restore of a Database
  • Backup and Restore of the Whole Cluster

Page 3: Logical Backup

SQL commands to create objects and fill them with data

    • backup of a particular object or a database
    • restore to a different architecture or PostgreSQL version
    • (binary compatibility is not required)
    • ease of use
  • − modest operation speed
  • − no point-in-time recovery

Speaker notes

A logical backup is a set of SQL commands that can restore the database cluster (or a particular database or table) from scratch: it creates all the required objects and fills them with data.

These commands can be run on a different server version (if it provides compatibility at the command level) or on a different platform or architecture (binary compatibility is not required).

In particular, a logical backup can be used for long-term storage: you can restore it even after upgrading the server to a higher version.

The process of creating a logical backup is relatively easy. It is usually enough to run a single command or launch a single utility.

But for large databases, the execution of these commands can take a very long time. Using a logical backup, you can restore your database system only to its state exactly to the point in time when the backup process was initiated.

https://postgrespro.com/docs/postgresql/16/backup-dump

Page 4: Physical Backup

A copy of the database cluster’s file system

    • faster than logical backup
    • statistics are restored
  • − restore is only possible on a compatible system, with the same PostgreSQL
  • major version installed
  • − partial backup is impossible, the whole cluster is copied
  • WAL archive
      • point-in-time recovery is available

Speaker notes

A physical backup implies creating a copy of all files related to the database cluster: in other words, creating its full binary copy.

It is faster to copy files than dump SQL commands; besides, unlike restoring a logical backup, starting a server using a physical copy is a matter of several minutes. Another advantage is that you do not have to rebuild statistical data: it is also restored from the physical copy.

But this approach has its own shortcomings. A physical backup can be used to restore the system only on a compatible platform (that has the same OS, architecture, etc.) with the same major PostgreSQL version installed. Besides, it is impossible to create a physical copy of a specific database: you can only back up the whole database cluster.

Physical backups are usually used together with WAL archives. It enables system recovery not only at the time of backup creation, but also at an arbitrary point in time.

https://postgrespro.com/docs/postgresql/16/backup-file

https://postgrespro.com/docs/postgresql/16/continuous-archiving

Creating physical backups for any important production systems is a common practice. It is the responsibility of a DBA to take such backups.

Page 5: Making a Table Copy in SQL

Source slide page 5: Making a Table Copy in SQL{#source-figure-005 caption=“Source slide page 5: Making a Table Copy in SQL”}

YKS Yakutsk (129.770
MJZ Mirny (114.039
KHV Khabarovsk (1
PKC Yelizovo (158.453
UUS Khomutovo (142.718
VVO Vladivostok (1
LED Pulkovo (30.2625
KGD Khrabrovo (20.5925
KEJ Kemerovo (86.1072
CEK Chelyabinsk (61.5033
MQF Magnitogorsk (5
PEE Perm (02/56-11
SGC Surgut (73.4018
  • the file is located in the server file system and can be accessed by the owner of the PostgreSQL instance you can specify the columns to copy (or use an arbitrary query) restored rows are added to already existing ones

Speaker notes

If you want to save only the contents of one table, you can use the COPY command.

The COPY TO flavor of this command enables you to save the table (or some of its columns, or even the result of an arbitrary query) into a file, display it in the terminal, or provide it as input to an application. You can specify parameters such as format (plain text, CSV or binary), field separators, NULL representation, and more.

The alternative variant of the COPY command reads fields from a file or the console and inserts them into a table. The table isn’t cleared, the new rows are simply appended to the existing ones.

The COPY command is significantly faster than similar INSERT commands, because the client does not need to access the server repeatedly, and the server does not have to analyze the commands multiple times.

Notably, the COPY FROM command ignores the defined rules, although integrity constraints and triggers are respected.

https://postgrespro.com/docs/postgresql/16/sql-copy

Page 6: Making a Table Copy in psql

Source slide page 6: Making a Table Copy in psql{#source-figure-006 caption=“Source slide page 6: Making a Table Copy in psql”}

YKS Yakutsk (129.770
MJZ Mirny (114.039
KHV Khabarovsk (1
PKC Yelizovo (158.453
UUS Khomutovo (142.718
VVO Vladivostok (1
LED Pulkovo (30.2625
KGD Khrabrovo (20.5925
KEJ Kemerovo (86.1072
CEK Chelyabinsk (61.5033
MQF Magnitogorsk (5
PEE Perm (02/56-11
SGC Surgut (73.4018
  • the file is located in the client file system and can be accessed by the OS user who has started psql the data is transferred between the client and the server the syntax and the supported features are analogous to those provided by COPY

Speaker notes

In psql, there is a client version of the COPY command with a similar syntax.

The file name in the SQL command corresponds to a file on the database server. The user running PostgreSQL (usually postgres) must have access to this file.

The client implementation of this command refers to the file located on the client, which allows keeping a local copy of data even if there is no access to the server file system. The table contents is automatically transferred between the client and the server.

https://postgrespro.com/docs/postgresql/16/app-psql

Page 7: The COPY Command

Create a database and a table.

=> CREATE DATABASE backup_logical_dev_1;
CREATE DATABASE
=> \c backup_logical_dev_1
You are now connected to database "backup_logical_dev_1" as user "student".
=> CREATE TABLE t(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    s text
);
CREATE TABLE
=> INSERT INTO t(s) VALUES ('Hello world!'), (''), (NULL);
INSERT 0 3

This is what the COPY command shows (output to the console, not to a file):

=> COPY t TO stdout;
1
Hello world!
2
3
\N

You can see how empty rows are different from the NULL values. The output format can be configured quite flexibly. You can change the separator, the NULL display, add a line with column names, etc. For example:

=> COPY t TO stdout WITH (NULL '<NULL>', DELIMITER ',', HEADER);
id,s
1,Hello world!
2,
3,<NULL>
Note

Note Note that the inline separator character has been escaped (the escape character is also configurable). Instead of a table, you can specify an arbitrary query.

=> COPY (SELECT * FROM t WHERE s IS NOT NULL) TO stdout;
1
Hello world!
2

This way, you can save query results, presentation data, etc. The command can work with CSV, a popular format supported by many programs.

=> COPY t TO stdout WITH (FORMAT csv);
1,Hello world!
2,""
3,

Input from file or console works in a similar manner. For input from console, the end of file marker (a backslash and a period) is required. For input from file, it is there implicitly. All input parameters must match those specified during output.

=> TRUNCATE TABLE t;
TRUNCATE TABLE
=> COPY t FROM stdin;
1
Hello world!
2
3
\N
\.
COPY 3

Page 8: Page 8

If the input contains a header row with column names, those can be verified against the target table column names.

=> COPY t FROM stdin (HEADER MATCH);
id
s
4
Row four
\.
COPY 1

A mismatch throws an error:

=> COPY t FROM stdin (HEADER MATCH);
Col_1
Col_2
5
Row five
\.
ERROR:  column name mismatch in header line field 1: got "Col_1", expected "id"
CONTEXT:  COPY t, line 1: "Col_1
Col_2"

Another attempt to add a row to the table:

=> INSERT INTO t (s) VALUES ('Row five');
ERROR:  duplicate key value violates unique constraint "t_pkey"
DETAIL:  Key (id)=(4) already exists.

This error occurred because the last COPY command, while successful, did not advance the value of the sequence serving the id column. This is easily fixed:

=> SELECT pg_catalog.setval('t_id_seq', 4, true);
 setval
--------
      4
(1 row)
=> INSERT INTO t (s) VALUES ('Row five');
INSERT 0 1

Here are the table contents (with NULL output configured in psql for clarity):

=> \pset null '\\N'
Null display is "\N".
=> SELECT * FROM t;
 id |      s
----+--------------
  1 | Hello world!
  2 |
  3 | \N
  4 | Row four
  5 | Row five
(5 rows)

Page 9: Database Backup

Source slide page 9: Database Backup{#source-figure-009 caption=“Source slide page 9: Database Backup”}

--
-- PostgreSQL database dum
--
-- Dumped from database ve
-- Dumped by pg_dump versi
SET statement_timeout = 0;
SET lock_timeout = 0;
SET
idle_in_transaction_sessio
SET client_encoding = 'UTF
SET standard_conforming_st
  • format: SQL commands you can specify the database objects to be dumped the new database must be created from the template0 template roles and tablespaces must be created in advance it makes sense to perform ANALYZE after restoring

Speaker notes

The pg_dump utility creates a full-scale database backup.

If you omit the file name (-f, –file), the utility’s output will be displayed in the terminal. The produced output is a script to be run in psql; it contains the commands that will create the required objects and fill them with data.

You can use optional parameters to limit the set of backed up objects: for example, you can choose to back up only particular tables, objects in particular schemas, or use other filters.

To restore the objects from the backup, just run the generated script in psql.

Note that the database to be restored should be cloned from template0, since all the changes made to template1 will also make it into the backup.

Besides, all the required roles and tablespaces must be set up in advance. Since these objects do not belong to any particular database, they won’t be included into the dump.

Once the database is restored, it makes sense to run the ANALYZE command: it will collect statistics that the optimizer requires for query planning.

https://postgrespro.com/docs/postgresql/16/app-pgdump

Page 10: The custom Format

Source slide page 10: The custom Format{#source-figure-010 caption=“Source slide page 10: The custom Format”}

;
; Archive created at 2017-
; dbname: demo
; TOC Entries: 146
; Compression: -1
; Dump Version 1.12-0
; Format CUSTOM
;
; Selected TOC Entries:
;
2297; 1262 475453 DATABASE
5; 2615 475454 SCHEMA - bo
2298; 0 0 COMMENT - SCHEMA
  • an internal format with a table of contents (TOC) database objects to be restored can be selected at the time of restore restoring can be performed in parallel mode

Speaker notes

The pg_dump utility allows you to specify the backup format. By default, the plain format is used; it provides pure psql commands.

The custom format (-F c, –format=custom) creates a backup in a special format that contains not only the backed up objects, but also a table of contents (TOC). Having a TOC allows you to choose the objects right at the time of restore, not while making the dump.

By default, the output of the custom format is compressed.

To restore the database, you need to run another utility: pg_restore. It reads the file and converts it to psql commands. If you do not explicitly provide the database name (with the -d option), all commands will be output to the terminal. If the database is specified, pg_restore will connect to this database and execute the commands; you won’t have to start psql.

To restore only some of the objects, you can use one of the following approaches. The first one is to filter the objects to be restored, just like it is done in pg_dump. In fact, pg_restore shares many parameters with pg_dump.

The second option is to use the TOC to retrieve the list of objects included into the backup (via the –list option). Then you can edit this list manually: delete the objects you do not need and pass the modified list to pg_restore (via the –use-list option).

https://postgrespro.com/docs/postgresql/16/app-pgrestore.html

Page 11: The directory Format

Source slide page 11: The directory Format{#source-figure-011 caption=“Source slide page 11: The directory Format”}

the directory contains a separate file for each database object and a TOC database objects to be restored can be selected at the time of restoration both dump and restore operations can be performed in parallel mode

Speaker notes

You can also create backups in the directory format. In this case, pg_dump produces a whole directory instead of a single file; it contains the backed up objects and the table of contents. By default, all files in the directory are compressed.

Its advantage over the custom format is that such a backup can be created concurrently using several processes (the number of processes is specified in option -j, –jobs).

Naturally, the backup will contain consistent data even though it has been created concurrently. Consistency is ensured by using a single data snapshot for all the parallel processes.

https://postgrespro.com/docs/postgresql/16/functions-admin.html#FUNCTIO NS-SNAPSHOT-SYNCHRONIZATION

Data restoration can also be performed in parallel mode (it is also supported for the custom format).

Other capabilities are quite similar to those provided by the previously discussed formats: the directory format supports the same options and approaches.

Page 12: Format Comparison

plain custom directory tar

  • restoration utility psql pg_restore
  • compression zlib
  • partial restore yes yes yes
  • parallel backup yes
  • parallel restore yes yes

Speaker notes

This slide compares different features provided by different backup formats.

Note that there is also one more format available: tar. We do not cover it here as it does not bring anything new and has no advantages as compared to other formats. In fact, this format is simply a version of the directory format in a tar archive, but it does not support compression or parallel execution.

Page 13: pg_dump Utility

pg_dump, ran without additional parameters, issues SQL commands that create all objects in the database:

student$ pg_dump -d backup_logical_dev_1
--
-- PostgreSQL database dump
--
\restrict BhvJqabsTTcnVrzf753kdI4bPo550nCEVg2WPU0H3thFxSnG4aCGKISwbcfAYZl
-- Dumped from database version 16.11 (Ubuntu 16.11-1.pgdg24.04+1)
-- Dumped by pg_dump version 16.11 (Ubuntu 16.11-1.pgdg24.04+1)
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: t; Type: TABLE; Schema: public; Owner: student
--
CREATE TABLE public.t (
    id integer NOT NULL,
    s text
);
ALTER TABLE public.t OWNER TO student;
--
-- Name: t_id_seq; Type: SEQUENCE; Schema: public; Owner: student
--
ALTER TABLE public.t ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY (
    SEQUENCE NAME public.t_id_seq
    START WITH 1
    INCREMENT BY 1
    NO MINVALUE
    NO MAXVALUE
    CACHE 1
);
--
-- Data for Name: t; Type: TABLE DATA; Schema: public; Owner: student
--
COPY public.t (id, s) FROM stdin;
1
Hello world!
2
3
\N
4
Row four
5
Row five
\.
--
-- Name: t_id_seq; Type: SEQUENCE SET; Schema: public; Owner: student
--
SELECT pg_catalog.setval('public.t_id_seq', 5, true);

Page 14: Page 14

Source slide page 14: Page 14{#source-figure-014 caption=“Source slide page 14: Page 14”}

--
-- Name: t t_pkey; Type: CONSTRAINT; Schema: public; Owner: student
--
ALTER TABLE ONLY public.t
    ADD CONSTRAINT t_pkey PRIMARY KEY (id);
--
-- PostgreSQL database dump complete
--
\unrestrict BhvJqabsTTcnVrzf753kdI4bPo550nCEVg2WPU0H3thFxSnG4aCGKISwbcfAYZl

Here, pg_dump has created the table t, added automatic ID generation, populated the table using the COPY command from before, and finally added a primary key integrity constraint. The –column-inserts flag enables the use of INSERT commands, but the process will take significantly longer. Here are some useful options that come in handy when restoring to a system with a different set of roles: -O, –no-owner — do not generate commands to set the owner of objects -x, –no-acl — do not generate commands to set privileges –no-comments — do not generate comments For dumping and uploading data in segments: -s, –schema-only — back up only object definitions without data -a, –data-only — back up only data without creating objects For restoring to a system already containing some data (or, on the other hand, to a completely clean system): -c, –clean — generate DROP commands for created objects -C, –create — generate commands for creating a database and connecting to it

Note

Important: changes to the template1 database are dumped too. Therefore, you should restore to a database created from template0. The –create option takes it into account automatically:

student$ pg_dump --create -d backup_logical_dev_1 | grep 'CREATE DATABASE'
CREATE DATABASE backup_logical_dev_1 WITH TEMPLATE = template0 ENCODING = 'UTF8'
LOCALE_PROVIDER = libc LOCALE = 'en_US.UTF-8';

No command to create the public schema is generated, but if its permissions or owner were changed at some point, corresponding commands will appear in pg_dump output:

=> ALTER SCHEMA public OWNER TO student;
ALTER SCHEMA
student$ pg_dump --create -d backup_logical_dev_1 | grep 'SCHEMA'
-- Name: public; Type: SCHEMA; Schema: -; Owner: student
ALTER SCHEMA public OWNER TO student;

Some options specify the objects to be dumped: -n, –schema — template for schema names -t, –table — template for table names Or, conversely, not to be, while dumping everything else: -N, –exclude-schema — template for schema names -T, –exclude-table — template for table names For example, restore the table t to another database.

=> CREATE DATABASE backup_logical_dev_2;
CREATE DATABASE
student$ pg_dump --table=t -d backup_logical_dev_1 | psql -d backup_logical_dev_2
SET
SET
SET
SET
SET
 set_config
------------

Page 15: Page 15

(1 row)
SET
SET
SET
SET
SET
SET
CREATE TABLE
ALTER TABLE
ALTER TABLE
COPY 5
 setval
--------
      5
(1 row)
ALTER TABLE

Connect to backup_logical_dev_2 and verify:

=> \c backup_logical_dev_2
You are now connected to database "backup_logical_dev_2" as user "student".
=> SELECT * FROM t;
 id |      s
----+--------------
  1 | Hello world!
  2 |
  3 | \N
  4 | Row four
  5 | Row five
(5 rows)

pg\_dump: custom Format

The plain format has a serious limitation: you have to specify all the objects you will need in your backup before making the dump. The custom format allows you to make a full dump first, then specify which objects to restore from it later.

student$ pg_dump --format=custom -d backup_logical_dev_1 -f /home/student/backup_logical_dev_1.custom

This is done with the pg_restore tool. Time to restore t again.

=> DROP TABLE t;
DROP TABLE

pg_restore will recognize the dump format automatically, there is no need to specify it. pg_restore also understands all the flags that pg_dump does, and more: -I, –index — restore specific indexes -P, –function — restore certain functions -T, –trigger — restore specific triggers

student$ pg_restore --table=t -d backup_logical_dev_2 /home/student/backup_logical_dev_1.custom
=> SELECT * FROM t;
 id |      s
----+--------------
  1 | Hello world!
  2 |
  3 | \N
  4 | Row four
  5 | Row five
(5 rows)

Another example: restore the entire original backup_logical_dev_1.

=> DROP DATABASE backup_logical_dev_1;
DROP DATABASE

-d lets us select any existing database, –create creates the database specified in the dump and connects to it.

student$ pg_restore --create -d student /home/student/backup_logical_dev_1.custom

Page 16: Page 16

Let’s try:

=> \c backup_logical_dev_1
You are now connected to database "backup_logical_dev_1" as user "student".
=> SELECT * FROM t;
 id |      s
----+--------------
  1 | Hello world!
  2 |
  3 | \N
  4 | Row four
  5 | Row five
(5 rows)

A plain backup can be modified in any text editor, if necessary. A custom backup is binary, but it still supports wider filtering capabilities, in addition to the previously mentioned flags. pg_restore can generate a list of objects, the table of contents of the backup:

student$ pg_restore --list /home/student/backup_logical_dev_1.custom
;
; Archive created at 2025-11-27 14:26:23 MSK
;     dbname: backup_logical_dev_1
;     TOC Entries: 10
;     Compression: gzip
;     Dump Version: 1.15-0
;     Format: CUSTOM
;     Integer: 4 bytes
;     Offset: 8 bytes
;     Dumped from database version: 16.11 (Ubuntu 16.11-1.pgdg24.04+1)
;     Dumped by pg_dump version: 16.11 (Ubuntu 16.11-1.pgdg24.04+1)
;
;
; Selected TOC Entries:
;
5; 2615 2200 SCHEMA - public student
216; 1259 16388 TABLE public t student
215; 1259 16387 SEQUENCE public t_id_seq student
3431; 0 16388 TABLE DATA public t student
3438; 0 0 SEQUENCE SET public t_id_seq student
3286; 2606 16394 CONSTRAINT public t t_pkey student

You can save it to a file, adjust it, and then use it during restore (–use-list).

pg\_dump: directory Format

The directory format, notably, supports dumping in parallel. Consistency is guaranteed: all processes use the same snapshot.

student$ pg_dump --format=directory --jobs=2 -d backup_logical_dev_1 -f
/home/student/backup_logical_dev_1.directory

Take a look inside the directory:

student$ ls -l /home/student/backup_logical_dev_1.directory
total 8
-rw-rw-r-- 1 student student   65 Nov 27 14:26 3430.dat.gz
-rw-rw-r-- 1 student student 2286 Nov 27 14:26 toc.dat

There is a table of contents file, and a file for each dumped object (we have only one):

student$ zcat /home/student/backup_logical_dev_1.directory/3430.dat.gz
1
Hello world!
2
3
\N
4
Row four
5
Row five
\.

Let’s restore backup_logical_dev_1 database from backup in two streams. The –clean option generates a command to remove the existing database, since backup_logical_dev_1 already exists. We also need to disconnect from backup_logical_dev_1:

=> \c backup_logical_dev_2

Page 17: Page 17

You are now connected to database "backup_logical_dev_2" as user "student".
student$ pg_restore --clean --create --jobs=2 -d student /home/student/backup_logical_dev_1.directory

Page 18: Cluster Backup

Source slide page 18: Cluster Backup{#source-figure-018 caption=“Source slide page 18: Cluster Backup”}

--
-- PostgreSQL database clu
--
SET default_transaction_re
SET client_encoding = 'UTF
SET standard_conforming_st
--
-- Roles
--
CREATE ROLE postgres;
  • format: SQL commands dumps the whole cluster, including roles and tablespaces the user must have access to all objects in the database cluster parallel backups are not supported

Speaker notes

pg_dumpall creates a backup of the entire cluster, including roles and tablespaces.

Since pg_dumpall requires access to all objects of all databases, it should be run by a superuser or a user with the pre-defined role pg_read_all_data. pg_dumpall connects to each database in the cluster one by one and backups them using pg_dump. In addition, it also stores data related to the cluster as a whole.

To start this process, pg_dumpall has to establish a connection with any available database. By default, postgres or template1 is selected, but you can specify another one.

The result of pg_dumpall is a script for psql. Other formats are not supported. This means that pg_dumpall does not support parallel execution, which can be a problem for larger clusters. In this case, you can use the –globals-only option to dump only roles and tablespaces, and dump the databases separately using pg_dump in parallel mode.

https://postgrespro.com/docs/postgresql/16/app-pg-dumpall

Page 19: pg_dumpall Utility

The pg_dump utility is good for dumping a single database, but it never dumps shared database cluster objects such as roles and tablespaces. To create a full cluster backup, you need to use pg_dumpall. All these tools do not require any specific privileges, but the role executing them must be allowed to read (create) all affected objects. For example, pg_dump can be used by the database owner. But, since pg_dumpall needs access to all databases, we will use a superuser role.

student$ pg_dumpall --clean -f /home/student/main.sql

A cluster backup includes some additional commands:

student$ grep 'ROLE' /home/student/main.sql
DROP ROLE postgres;
DROP ROLE student;
CREATE ROLE postgres;
ALTER ROLE postgres WITH SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN REPLICATION
BYPASSRLS PASSWORD
'SCRAM-SHA-256$4096:/5SucUY8Iy5wpaWeYWna8w==$yM3QjdWerSdlUVfxx8zzRfh+mYX1Bj5stpgbe/MjMKA=:
piQRyiz9kJYQiCoTzvBKRayeSSpSKsH29fIBD9QPIGM=';
CREATE ROLE student;
ALTER ROLE student WITH SUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION
NOBYPASSRLS PASSWORD
'SCRAM-SHA-256$4096:e7y80Gn0AnRXi8ykDUHQUw==$s2Nu8GaTRKJCLqDFZdE/u7J3VvTjPLz03c2icvvg+8g=:
D1KNqVz1YOGeCrBw1pbBd3SE9RWqIxklrBTnnEvxDsM=';

Restoring is done via psql, there is no other way around it. The command to restore (we will not run it):

student$ psql -f main.sql

During restoring, errors related to objects already existing may come up. These are normal and do not affect the restoration, but it is a good practice to keep an eye on all error messages nevertheless.

Page 20: Takeaways

You can make a logical backup of a whole cluster, a specific database or specific database objects

  • Good for
    • small amounts of data
    • long-term storage during which the server can been upgraded
    • migration to a different platform
  • Not so good for
    • crash recovery with minimal data loss

Page 21: Practice

  1. As an employee, back up the bookstore database in the custom format.
  • “Accidentally” empty the authorship table. Check that the
  • application has stopped displaying book titles in Bookstore,
  • Books, and Catalog tabs.
  • Use the backup to restore the lost data.
  • Verify that normal operation of the bookstore is restored.

Speaker notes

  1. Include the user into the pre-defined role pg_read_all_data. Use the –data-only option for the restore operation, as an attempt to create the table will result in an error.

Page 22: 1. Restoring the Lost Data

Include the employee user into pg_read_all_data pre-defined role and create logical backup on their behalf:

=> GRANT pg_read_all_data TO employee;
GRANT ROLE
student$ pg_dump --format=custom 'host=localhost user=employee dbname=bookstore password=employee' >
/home/student/bookstore.custom

Delete the rows:

=> DELETE FROM authorship;
DELETE 10

The employee user would be unable to restore because they lack the privilege to insert rows into the table, hence we do it as student:

student$ pg_restore -t authorship --data-only -d bookstore /home/student/bookstore.custom
=> SELECT count(*) FROM authorship;
 count
-------
    10
(1 row)

Page 23: Practice+

  1. The psql \copy command lets you pass the output as input to an arbitrary application. Use this capability to open the result of some query in LibreOffice Calc.

Speaker notes

  1. The command must save the result into a file and then start libreoffice with this file passed as a parameter. The file must be saved in the CSV format.

Naturally, this approach is platform-dependent and will require modifications, say, on Windows.

Page 24: 1. Open Query Results in LibreOffice

Create the table.

=> CREATE DATABASE backup_logical_dev;
CREATE DATABASE
=> \c backup_logical_dev
You are now connected to database "backup_logical_dev" as user "student".
=> CREATE TABLE t(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    s text
);
CREATE TABLE
=> INSERT INTO t(s) VALUES ('foo'), ('bar'), ('baz');
INSERT 0 3

Dump the table contents into CSV file:

=> \copy t TO PROGRAM 'cat > t.csv' WITH (format csv);
COPY 3

If we use COPY SQL command instead of \copy, the utility runs on the server, it would be incorrect. Opening /home/student/t.csv…

student$ xdg-open /home/student/t.csv
=> \c postgres
You are now connected to database "postgres" as user "student".
=> DROP DATABASE backup_logical_dev;
DROP DATABASE