Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

PostgreSQL in Depth  Module 1 – How PostgreSQL works and how to run it

psql for daily work: meta-commands and scripts

Use psql 18 every day: describe a database with \d commands, write scripts with variables, \if, \gexec and ON_ERROR_STOP, copy files, and pipeline queries.

  • Beginner
  • 10 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Navigate a database with the \d family of meta-commands and expanded output
  • Write repeatable psql scripts with variables, \if, ON_ERROR_STOP and \gexec
  • Move data with \copy and explain how it differs from server-side COPY
  • Test named prepared statements and pipelines with psql 18's \bind_named and pipeline commands

Before you start

What you’ll learn · Before you start

What you will learn

  • Navigate a database with the \d family of meta-commands and expanded output
  • Write repeatable psql scripts with variables, \if, ON_ERROR_STOP and \gexec
  • Move data with \copy and explain how it differs from server-side COPY
  • Test named prepared statements and pipelines with psql 18's \bind_named and pipeline commands

Before you start

On this page

A maintenance script of 40 statements fails at the seventh, and psql runs the other 33 anyway: by default it reports an error and carries on. psql, the client that ships with PostgreSQL, answers questions at a prompt with its backslash meta-commands, and the same tool runs files of SQL as scripts, where variables, \if and ON_ERROR_STOP decide whether a script stops safely or leaves half its work done.

You will find your way around a database with the \d commands, write scripts that use variables, make decisions with \if and stop at the first error, turn catalog queries into statements with \gexec, move files with \copy, and send prepared statements and pipelines with the commands new in psql 18.

The \d commands describe a database from its catalogs

Every \d command is a meta-command: A psql command that starts with a backslash, such as \d, \copy or \gexec. psql handles it itself instead of sending it to the server, often by running catalog queries for you., a line starting with a backslash that psql handles itself, and each one is a query on the system catalogs that psql writes for you. \dt+ lists tables, \di+ indexes, \d name one object in full, and the + adds sizes and descriptions. The run creates the shop database of this lesson (three customers, 3,000 orders, an audit log) and describes it with psql’s -a option, which echoes every line of the script before its output:

Describing the shop database with \dt+, \di+, \d and \x auto

describe_session.py

"""Finding your way around a database with psql's describe commands and expanded output."""

from pg_session import Server

with Server(settings={"autovacuum": "off"}) as pg:
    pg.up()
    pg.shell("createdb", "shop")
    pg.psql("setup.sql", db="shop", flags=("-q",))
    pg.psql("describe.sql", db="shop")

Output

$ createdb shop

$ psql -d shop -X -q -f setup.sql

$ psql -d shop -X -a -f describe.sql
\dt+
                                         List of tables
 Schema |   Name    | Type  |  Owner   | Persistence | Access method |    Size    | Description 
--------+-----------+-------+----------+-------------+---------------+------------+-------------
 public | audit_log | table | postgres | permanent   | heap          | 8192 bytes | 
 public | customers | table | postgres | permanent   | heap          | 16 kB      | 
 public | orders    | table | postgres | permanent   | heap          | 184 kB     | 
(3 rows)

\di+
                                                   List of indexes
 Schema |        Name         | Type  |  Owner   |   Table   | Persistence | Access method |    Size    | Description 
--------+---------------------+-------+----------+-----------+-------------+---------------+------------+-------------
 public | audit_log_pkey      | index | postgres | audit_log | permanent   | btree         | 8192 bytes | 
 public | customers_pkey      | index | postgres | customers | permanent   | btree         | 16 kB      | 
 public | orders_customer_idx | index | postgres | orders    | permanent   | btree         | 40 kB      | 
 public | orders_pkey         | index | postgres | orders    | permanent   | btree         | 88 kB      | 
(4 rows)

\d orders
                               Table "public.orders"
   Column    |     Type      | Collation | Nullable |           Default            
-------------+---------------+-----------+----------+------------------------------
 id          | bigint        |           | not null | generated always as identity
 customer_id | integer       |           | not null | 
 total       | numeric(10,2) |           | not null | 
 placed      | date          |           | not null | 
Indexes:
    "orders_pkey" PRIMARY KEY, btree (id)
    "orders_customer_idx" btree (customer_id)
Foreign-key constraints:
    "orders_customer_id_fkey" FOREIGN KEY (customer_id) REFERENCES customers(id)

-- \x auto turns a result wider than the screen into one block per row. psql writing to a file has no
-- screen width, so \pset columns sets one: 80 characters.
\pset columns 80
Target width is 80.
\x auto
Expanded display is used automatically.
SELECT name, setting, unit, context, short_desc FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
-[ RECORD 1 ]---------------------------------------------------------
name       | lock_timeout
setting    | 0
unit       | ms
context    | user
short_desc | Sets the maximum allowed duration of any wait for a lock.
-[ RECORD 2 ]---------------------------------------------------------
name       | work_mem
setting    | 4096
unit       | kB
context    | user
short_desc | Sets the maximum memory to be used for query workspaces.

SELECT name, setting, unit FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
     name     | setting | unit 
--------------+---------+------
 lock_timeout | 0       | ms
 work_mem     | 4096    | kB
(2 rows)

\x off
Expanded display is off.

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

setup.sql

-- The shop database of this lesson: three customers, 3,000 orders and an empty audit log.
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL);
CREATE TABLE orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id int NOT NULL REFERENCES customers,
  total numeric(10,2) NOT NULL,
  placed date NOT NULL
);
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE TABLE audit_log (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, note text NOT NULL);
INSERT INTO customers VALUES (1, 'Asha Rao', 'Pune'), (2, 'Imran Khan', 'Lucknow'), (3, 'Meera Iyer', 'Chennai');
INSERT INTO orders (customer_id, total, placed)
SELECT 1 + n % 3, 100 + n % 900, date '2026-01-01' + n % 200 FROM generate_series(1, 3000) AS n;
ANALYZE orders;
CREATE ROLE clerk LOGIN;
GRANT SELECT, INSERT ON customers, orders, audit_log TO clerk;

describe.sql

\dt+
\di+
\d orders
-- \x auto turns a result wider than the screen into one block per row. psql writing to a file has no
-- screen width, so \pset columns sets one: 80 characters.
\pset columns 80
\x auto
SELECT name, setting, unit, context, short_desc FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
SELECT name, setting, unit FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
\x off

pg_session.py

"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.

Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
learn:verify's run sandbox cannot read the home folder, where mise keeps PostgreSQL, so these sessions are recorded
with LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. The sessions never print the port. A session stops with status 1 when
a command fails that should have worked, or reports a different number of errors than the session expects, so a run
that went wrong is never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""

    def __init__(self, settings=None, initdb=(), name="data"):
        self.settings = dict(settings or {})
        self.initdb_args = list(initdb)
        self.name = name
        self.running = []

    def __enter__(self):
        if not (BIN / "postgres").exists():
            sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        (self.base / "pwfile").write_text(self.password + "\n")
        self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
                    "PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
        for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
            self.env.pop(key, None)
        for script in Path(".").glob("*.sql"):
            shutil.copy(script, self.base / script.name)
        for data in Path(".").glob("*.csv"):
            shutil.copy(data, self.base / data.name)
        return self

    def __exit__(self, *exc):
        for folder, _port in reversed(self.running):
            self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
        self.running = []
        shutil.rmtree(self.base, ignore_errors=True)

    # ---- the cluster ---------------------------------------------------------------------------------------------
    def initdb(self, show=False):
        args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
                *self.initdb_args]
        if not show:
            args.append("--no-instructions")
        self._expect(args, show=show)
        with open(self.base / self.name / "postgresql.conf", "a") as conf:
            conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
            conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
            for key, value in self.settings.items():
                conf.write(f"{key} = {value}\n")

    def start(self, show=False, folder=None, port=None):
        folder = folder or self.name
        self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
        self.running.append((folder, port or self.port))

    def up(self):
        """initdb and start, quietly."""
        self.initdb()
        self.start()
        return self

    def standby(self, folder="standby"):
        """A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
        self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
        port = free_port()
        with open(self.base / folder / "postgresql.conf", "a") as conf:
            conf.write(f"port = {port}\n")
        self.start(folder=folder, port=port)
        for _ in range(300):
            if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
                return port
            time.sleep(0.1)
        sys.exit("the standby did not start streaming")

    # ---- running things ------------------------------------------------------------------------------------------
    def _call(self, args, env=None):
        args = [str(a) for a in args]
        done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                              text=True, cwd=self.base, env=env or self.env)
        return done.returncode, self.clean(done.stdout)

    def _expect(self, args, show=False, status=0):
        code, out = self._call(args)
        if show:
            _show(args, out)
        if code != status:
            if not show:
                print(out)
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
        text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
        text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
        return text.replace(f'"{OS_USER}"', '"learner"')

    def wait_for(self, sql, value):
        """Waits (unprinted) until a query returns the value, for example after a reload."""
        for _ in range(300):
            if self.query(sql) == value:
                return
            time.sleep(0.1)
        sys.exit(f"{sql} never returned {value}")

    def show_file(self, path):
        """Prints a text file of the cluster, as `cat` would."""
        print(f"$ cat {path}")
        print((self.base / path).read_text().rstrip("\n"))
        print()

    def query(self, sql, port=None):
        """One value from one query, unprinted (the session uses it to wait or to decide)."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
        if code != 0:
            sys.exit(f"the query failed: {out}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only to give a role the run's password)."""
        self.query(sql)

    def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None):
        """Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
        `errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        if user:
            env["PGUSER"] = user
        if db:
            env["PGDATABASE"] = db
        args = ["psql", "-X", *flags, "-f", script]
        code, out = self._call(args, env=env)
        shown = ["psql", "-X", *flags, "-f", script]
        if user:
            shown[1:1] = ["-U", user]
        if db:
            shown[1:1] = ["-d", db]
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
        expected_status = 0 if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")

    def sessions(self, count):
        """Opens `count` idle psql connections (each one a client backend) until the with block ends."""
        return _Idle(self, count)

    def shell(self, *args, status=0):
        """Runs one of PostgreSQL's programs and prints the command and its output."""
        return self._expect(list(args), show=True, status=status)

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
        print(textwrap.fill("  ".join(names), width=78, initial_indent="   ", subsequent_indent="   ",
                            break_on_hyphens=False))
        print()
        return names


def _arg(a):
    """A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()


class _Idle:
    """Idle psql connections, closed again when the with block ends."""

    def __init__(self, server, count):
        self.server, self.count, self.procs = server, count, []

    def __enter__(self):
        before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
        for _ in range(self.count):
            self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
                                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                               env=self.server.env, cwd=self.server.base))
        sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
        for _ in range(300):
            # the query's own connection is one more client backend
            if int(self.server.query(sql)) == before + self.count:
                return self
            time.sleep(0.1)
        sys.exit("the idle connections did not open")

    def __exit__(self, *exc):
        for p in self.procs:
            p.stdin.close()
            p.wait(timeout=30)

\dt+ shows each table’s owner, persistence, access method and size: 184 kB for the 3,000 orders. \di+ lists four indexes, among them orders_pkey at 88 kB, more than the 40 kB of orders_customer_idx beside it. \d orders gives the columns with their types, nullability and defaults, then the indexes and the foreign key, which is what you check before you change a table.

The last two queries show \x auto. A result wider than the screen comes out as one block per row, column name on the left and value on the right, and a narrow one stays a table. psql writing to a file has no screen to measure, so the script set the width with \pset columns 80.

A few more of the family, all on the psql page of the manual: \df+ for functions, \dx for extensions, \dp for privileges and \du for roles. psql -E prints the catalog query behind each one, which is a quick way to learn the catalogs. During an incident, \timing on prints how long each statement took and \watch 5 repeats the last query every 5 seconds; psql 18 added the variable WATCH_INTERVAL to change the default wait of 2 seconds.

Checkpoint

Check: The \d commands and expanded output

  1. Question 1 of 2 What does the + add to \dt+ and \di+ in the recorded run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The size of each table or index, its persistence, access method and description

    \dt and \di list the objects; with + they add Persistence, Access method, Size and Description. psql -E prints the catalog query behind each one.

  2. Question 2 of 2 With \x auto and a width of 80 characters, the query of name, setting, unit, context and short_desc came out as one block per row, and the query of name, setting and unit as a table. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The first result was wider than 80 characters and the second fitted, and auto switches only wide results

    The descriptions made the first result wider than 80 characters. psql writing to a file has no screen to measure, so the script set the width with \pset columns 80.

Variables and \if let a script decide

psql variables live in psql, not in the server. \set name value sets one, :name puts it into the next line, :'name' puts it in as a quoted SQL literal and :"name" as a quoted identifier. \gset, in place of the semicolon, runs a query and stores its single row in variables named after its columns. The run contains a common mistake first, then a script that makes it work, and stops at an error on purpose:

Variables, \if and ON_ERROR_STOP, then a \gexec script

scripts_session.py

"""psql scripts with variables, \\if, \\gexec and ON_ERROR_STOP, run with -a so every line is echoed."""

from pg_session import Server

with Server(settings={"autovacuum": "off"}) as pg:
    pg.up()
    pg.shell("createdb", "shop")
    pg.psql("setup.sql", db="shop", flags=("-q",))
    pg.psql("if_mistake.sql", db="shop", errors=1)
    pg.psql("report.sql", db="shop", errors=1, status=3)
    pg.psql("gexec.sql", db="shop")

Output

$ createdb shop

$ psql -d shop -X -q -f setup.sql

$ psql -d shop -X -a -f if_mistake.sql
-- \if takes a boolean, not an expression: psql does no arithmetic and no comparison.
SELECT count(*) AS n FROM orders \gset
\echo :n orders
3000 orders
\if :n > 1000
psql:if_mistake.sql:4: error: unrecognized value "3000 > 1000" for "\if expression": Boolean expected
  \echo a busy shop
\endif

$ psql -d shop -X -a -f report.sql
-- A report script: stop at the first error, let the server compare, quote every value it splices in.
\set ON_ERROR_STOP on
SELECT count(*) AS n, count(*) > 1000 AS busy FROM orders \gset
\if :busy
  \echo :n orders: a busy shop
3000 orders: a busy shop
\else
  \echo :n orders: a quiet shop
\endif
\set city 'Pune'
SELECT c.name, count(*) AS orders, sum(o.total) AS spent
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.city = :'city'
GROUP BY c.name;
   name   | orders |   spent   
----------+--------+-----------
 Asha Rao |   1000 | 518800.00
(1 row)

SELECT * FROM order_totals;
psql:report.sql:14: ERROR:  relation "order_totals" does not exist
LINE 1: SELECT * FROM order_totals;
                      ^

$ psql -d shop -X -a -f gexec.sql
-- Turn a catalog query into a maintenance script: one ANALYZE for every table never analysed.
SELECT format('ANALYZE %I.%I', schemaname, relname)
FROM pg_stat_user_tables
WHERE last_analyze IS NULL AND last_autoanalyze IS NULL
ORDER BY relname \gexec
ANALYZE public.audit_log
ANALYZE
ANALYZE public.customers
ANALYZE
SELECT relname, last_analyze IS NOT NULL AS analysed FROM pg_stat_user_tables ORDER BY relname;
  relname  | analysed 
-----------+----------
 audit_log | t
 customers | t
 orders    | t
(3 rows)

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

if_mistake.sql

-- \if takes a boolean, not an expression: psql does no arithmetic and no comparison.
SELECT count(*) AS n FROM orders \gset
\echo :n orders
\if :n > 1000
  \echo a busy shop
\endif

report.sql

-- A report script: stop at the first error, let the server compare, quote every value it splices in.
\set ON_ERROR_STOP on
SELECT count(*) AS n, count(*) > 1000 AS busy FROM orders \gset
\if :busy
  \echo :n orders: a busy shop
\else
  \echo :n orders: a quiet shop
\endif
\set city 'Pune'
SELECT c.name, count(*) AS orders, sum(o.total) AS spent
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.city = :'city'
GROUP BY c.name;
SELECT * FROM order_totals;
\echo this line never runs

gexec.sql

-- Turn a catalog query into a maintenance script: one ANALYZE for every table never analysed.
SELECT format('ANALYZE %I.%I', schemaname, relname)
FROM pg_stat_user_tables
WHERE last_analyze IS NULL AND last_autoanalyze IS NULL
ORDER BY relname \gexec
SELECT relname, last_analyze IS NOT NULL AS analysed FROM pg_stat_user_tables ORDER BY relname;

In if_mistake.sql, \gset stored n = 3000, and \if :n > 1000 failed with ‘unrecognized value “3000 > 1000” … Boolean expected’. psql does no arithmetic and no comparisons: after it substitutes the variable, \if needs a word it reads as true or false, such as t, on or yes, and the manual says anything else is treated as false, so the block was skipped. report.sql lets the server compare instead, count(*) > 1000 AS busy, and \if :busy read t.

report.sql also put the city into its query as :'city', a quoted literal, which stays correct for a value such as O'Fallon and cannot end the string early. Then it ran SELECT * FROM order_totals, a table that does not exist. Because the script began with \set ON_ERROR_STOP on, psql stopped there with exit status 3, and the last \echo never ran. Without it, psql would have reported the error and gone on with the next line, exactly what went wrong with the 40-statement script.

One transaction for a script that must all run or not at all

psql -X --single-transaction -v ON_ERROR_STOP=on -f migrate.sql wraps the whole file in BEGIN and COMMIT, and with ON_ERROR_STOP it sends a ROLLBACK at the first error, so a half-applied script cannot happen. -X skips your personal ~/.psqlrc, so a script behaves the same for everyone.

Checkpoint

Check: Variables, \if and ON_ERROR_STOP

  1. Question 1 of 2 A script has n = 3000 from \gset and writes \if :n > 1000. The recorded run printed 'unrecognized value "3000 > 1000" … Boolean expected'. What is the fix?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Let the server compare, SELECT count(*) > 1000 AS busy … \gset, and test \if :busy

    After substitution, \if needs one of true, false, on, off, yes, no, 1 or 0 (or an unambiguous prefix). psql has no arithmetic, so the comparison belongs in a query.

  2. Question 2 of 2 A psql script that starts with \set ON_ERROR_STOP on reaches a statement that fails. With which exit status does psql end?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 3 exit status

    psql returns 3 when an error occurred in a script with ON_ERROR_STOP set, as the recorded report.sql did; 1 is for psql's own fatal errors and 2 for a lost connection.

\gexec turns the rows of a query into statements

\gexec runs a query and then sends every field of every row it returns to the server as a statement of its own. Combined with the catalogs, it writes maintenance scripts for you. gexec.sql, the last part of the run above, builds one ANALYZE for each table that was never analysed:

Interactive · message by message A catalog query that writes its own statements Step through the \gexec script of the lesson and see who builds each statement and who runs it.
A catalog query that writes its own statements, step 5 of 5. Messages so far: psql to PostgreSQL 18.6: SELECT format(…); PostgreSQL 18.6 to psql: 2 rows of SQL text; psql to PostgreSQL 18.6: ANALYZE public.audit_log; psql to PostgreSQL 18.6: ANALYZE public.customers; PostgreSQL 18.6 to psql: 3 tables analysed.psqlstatement 2 of 2PostgreSQL 18.6the shop databaseSELECT format(…)2 rows of SQL textANALYZE public.audit_logANALYZE public.customers3 tables analysed

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The query goes out. psql sends the SELECT, which asks pg_stat_user_tables for every table that was never analysed and builds a statement for each with format('ANALYZE %I.%I', …).
  2. Rows of text come back. The server answers with two rows, each one a piece of text, ANALYZE public.audit_log and ANALYZE public.customers. orders was analysed by the setup script, so it is not there.
  3. Each field becomes a query. Because the query ended with \gexec instead of a semicolon, psql does not print the rows. It sends each field to the server as a statement of its own, in the order the rows came.
  4. The second statement. The server analyses audit_log and reports ANALYZE; psql sends the second statement. Had one failed, the rest would still run unless ON_ERROR_STOP was on.
  5. The check. The script's last query reads pg_stat_user_tables again, and all three tables are now analysed. The catalog said what needed doing, and the same query did it.

The query returned ANALYZE public.audit_log and ANALYZE public.customers and psql ran both; orders had been analysed by the setup and was left out. format('ANALYZE %I.%I', schemaname, relname) matters: %I quotes an identifier when it needs quotes, so a table called Order Items becomes "Order Items" instead of two words that fail, or a name that changes the meaning of the statement.

Building statements by gluing names together

'ANALYZE ' || relname works until a table name has a capital letter, a space or a quote in it. Use format('… %I', name) for identifiers and %L for literals, or quote_ident and quote_literal, and look at the generated statements with a plain semicolon before you run them with \gexec.

Checkpoint

Check: Statements generated with \gexec

  1. Question 1 of 2 In the recorded gexec.sql, which statements did psql send to the server after the SELECT?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ANALYZE public.audit_log and ANALYZE public.customers, one at a time

    The catalog query decided what needed doing and \gexec did it: afterwards all three tables showed analysed.

  2. Question 2 of 2 A \gexec query builds GRANT statements with 'GRANT SELECT ON ' || relname || ' TO reporting'. It fails on a table called Order Items. What is the safe fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: format('GRANT SELECT ON %I TO reporting', relname), which quotes the identifier when needed

    Identifiers go through format's %I or quote_ident, literals through %L or quote_literal. Run the query with a semicolon first to read the statements it generates.

\copy reads files where psql runs, COPY where the server runs

SQL’s COPY moves rows between a table and a file, and the file is opened by the server, on the server’s machine, as the operating-system user the server runs as. psql’s \copy runs the same command but reads or writes the file itself, on the machine where psql runs, with your own permissions. The run logs in as clerk, a role that may read and insert rows and nothing more:

\copy in and out, and COPY with a file name

copy_session.py

"""\\copy against server-side COPY, run by the clerk role, which may read and insert rows but not server files."""

from pg_session import Server

with Server(settings={"autovacuum": "off"}) as pg:
    pg.up()
    pg.shell("createdb", "shop")
    pg.psql("setup.sql", db="shop", flags=("-q",))
    pg.silent(f"ALTER ROLE clerk PASSWORD '{pg.password}'")
    pg.psql("copy.sql", db="shop", user="clerk", errors=2)
    pg.show_file("customers_out.csv")

Output

$ createdb shop

$ psql -d shop -X -q -f setup.sql

$ psql -d shop -U clerk -X -a -f copy.sql
-- \copy: psql reads and writes the files, on the computer where psql runs.
\copy customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER)
COPY 2
\copy (SELECT id, name, city FROM customers ORDER BY id) TO 'customers_out.csv' WITH (FORMAT csv, HEADER)
COPY 5
-- COPY with a file name: the server opens the file, so the role needs the right to read server files.
COPY customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER);
psql:copy.sql:5: ERROR:  permission denied to COPY from a file
DETAIL:  Only roles with privileges of the "pg_read_server_files" role may COPY from a file.
HINT:  Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone.
-- A COPY FROM STDIN that fails at once: psql 18.6 skips its in-line data up to the \. line.
COPY customer FROM STDIN WITH (FORMAT csv);
psql:copy.sql:7: ERROR:  relation "customer" does not exist
SELECT count(*) AS customers FROM customers;
 customers 
-----------
         5
(1 row)

$ cat customers_out.csv
id,name,city
1,Asha Rao,Pune
2,Imran Khan,Lucknow
3,Meera Iyer,Chennai
4,Kabir Das,Varanasi
5,Leela Nair,Kochi

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

copy.sql

-- \copy: psql reads and writes the files, on the computer where psql runs.
\copy customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER)
\copy (SELECT id, name, city FROM customers ORDER BY id) TO 'customers_out.csv' WITH (FORMAT csv, HEADER)
-- COPY with a file name: the server opens the file, so the role needs the right to read server files.
COPY customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER);
-- A COPY FROM STDIN that fails at once: psql 18.6 skips its in-line data up to the \. line.
COPY customer FROM STDIN WITH (FORMAT csv);
6,Ravi Kumar,Indore
\.
SELECT count(*) AS customers FROM customers;

new_customers.csv

id,name,city
4,Kabir Das,Varanasi
5,Leela Nair,Kochi

\copy customers FROM 'new_customers.csv' loaded 2 rows from the file beside psql, and \copy (SELECT …) TO 'customers_out.csv' wrote all 5 rows back out, header first. The same load as SQL’s COPY … FROM 'new_customers.csv' failed: “permission denied to COPY from a file”, with the detail that only roles with the privileges of pg_read_server_files may do it. That is deliberate. A role that can make the server open any file could read the server’s own configuration and data, so the manual reserves it for superusers and those predefined roles, and anyone can still COPY to standard output or from standard input, which is what \copy uses.

Aspect COPY … FROM 'file' \copy … FROM 'file'
Who opens the file The server, as its operating-system user psql, as you
Where the file is On the database server’s machine On the machine where psql runs
Who may use it Superusers and members of pg_read_server_files Any role with INSERT on the table
Relative paths Relative to the server’s data directory Relative to psql’s current folder

psql 18.6 skips the data of a failed COPY FROM STDIN

The script’s last COPY named a table that does not exist, customer, and its in-line data followed. Since 18.6, psql skips that data up to the \. line instead of running it as SQL, a fix the release notes list as CVE-2026-6464; the run printed one error and went on to count 5 customers. Test scripts that fail a COPY on purpose must end its data with a \. line.

Checkpoint

Check: \copy against COPY

  1. Question 1 of 2 Who opens the file in \copy customers FROM 'new_customers.csv'?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: psql, on the machine where psql runs, with your own permissions

    The recorded clerk loaded 2 rows with \copy, while the same load with COPY and a file name was refused.

  2. Question 2 of 2 clerk may SELECT and INSERT on customers. Why did COPY customers FROM 'new_customers.csv' fail for clerk while \copy worked?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: COPY with a file name makes the server open the file, which needs superuser or pg_read_server_files

    Server-side file access is reserved for superusers and the roles pg_read_server_files, pg_write_server_files and pg_execute_server_program.

psql 18 prepares, binds and pipelines statements

Two groups of meta-commands are new in psql 18. \parse name prepares the query buffer as a named prepared statement: A statement the server has parsed and stored under a name for one session, so that it can be run many times with different parameter values ($1, $2 …) without being parsed again., \bind_named name value … followed by \g runs it with parameters, and \close_prepared name drops it, which lets you test from psql what an application driver does. \startpipeline … \endpipeline sends statements without waiting for each answer, and \syncpipeline marks a sync point inside:

A named prepared statement, then two pipelines

pipeline_session.py

"""psql 18's commands for named prepared statements and pipelines."""

from pg_session import Server

with Server(settings={"autovacuum": "off"}) as pg:
    pg.up()
    pg.shell("createdb", "shop")
    pg.psql("setup.sql", db="shop", flags=("-q",))
    pg.psql("pipeline.sql", db="shop", errors=1)

Output

$ createdb shop

$ psql -d shop -X -q -f setup.sql

$ psql -d shop -X -a -f pipeline.sql
-- A named prepared statement: parse once, bind and run it twice, close it.
SELECT count(*) AS orders, sum(total) AS spent FROM orders WHERE customer_id = $1 \parse orders_of

\bind_named orders_of 1 \g
 orders |   spent   
--------+-----------
   1000 | 518800.00
(1 row)

\bind_named orders_of 2 \g
 orders |   spent   
--------+-----------
   1000 | 519500.00
(1 row)

SELECT name, statement FROM pg_prepared_statements;
   name    |                                     statement                                      
-----------+------------------------------------------------------------------------------------
 orders_of | SELECT count(*) AS orders, sum(total) AS spent FROM orders WHERE customer_id = $1 
(1 row)

\close_prepared orders_of

SELECT count(*) AS prepared FROM pg_prepared_statements;
 prepared 
----------
        0
(1 row)

-- A pipeline: statements go out without waiting for answers; \syncpipeline ends one implicit transaction.
\startpipeline
INSERT INTO audit_log (note) VALUES ('one');
INSERT INTO audit_log (note) VALUES ('two');
\syncpipeline
INSERT INTO audit_log (note) VALUES ('three') RETURNING id;
\endpipeline
INSERT 0 1
INSERT 0 1
 id 
----
  3
(1 row)

INSERT 0 1
-- An error inside a pipeline rolls back its part of the pipeline and skips the rest of it.
\startpipeline
INSERT INTO audit_log (note) VALUES ('four');
SELECT 1 / 0;
INSERT INTO audit_log (note) VALUES ('five');
\endpipeline
INSERT 0 1
psql:pipeline.sql:20: ERROR:  division by zero
SELECT id, note FROM audit_log ORDER BY id;
 id | note  
----+-------
  1 | one
  2 | two
  3 | three
(3 rows)

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

pipeline.sql

-- A named prepared statement: parse once, bind and run it twice, close it.
SELECT count(*) AS orders, sum(total) AS spent FROM orders WHERE customer_id = $1 \parse orders_of
\bind_named orders_of 1 \g
\bind_named orders_of 2 \g
SELECT name, statement FROM pg_prepared_statements;
\close_prepared orders_of
SELECT count(*) AS prepared FROM pg_prepared_statements;
-- A pipeline: statements go out without waiting for answers; \syncpipeline ends one implicit transaction.
\startpipeline
INSERT INTO audit_log (note) VALUES ('one');
INSERT INTO audit_log (note) VALUES ('two');
\syncpipeline
INSERT INTO audit_log (note) VALUES ('three') RETURNING id;
\endpipeline
-- An error inside a pipeline rolls back its part of the pipeline and skips the rest of it.
\startpipeline
INSERT INTO audit_log (note) VALUES ('four');
SELECT 1 / 0;
INSERT INTO audit_log (note) VALUES ('five');
\endpipeline
SELECT id, note FROM audit_log ORDER BY id;

orders_of was parsed once and run twice, for customers 1 and 2, with 1,000 orders each; pg_prepared_statements listed it until \close_prepared dropped it. The pipelines need a closer look, because the second one lost a row that had been inserted. Step through both:

Interactive · message by message Two pipelines, one with a sync and one with an error Step through the audit-log inserts of the lesson and see when the server answers and which rows stay.
Two pipelines, one with a sync and one with an error, step 7 of 7. Messages so far: psql to PostgreSQL 18.6: INSERT one, INSERT two; psql to PostgreSQL 18.6: sync; psql to PostgreSQL 18.6: INSERT three, sync; PostgreSQL 18.6 to psql: results, in order; psql to PostgreSQL 18.6: four, 1 / 0, five; PostgreSQL 18.6 to psql: rows 1 to 3.psqlin a pipelinePostgreSQL 18.6four rolled backINSERT one, INSERT twosyncINSERT three, syncresults, in orderfour, 1 / 0, fiverows 1 to 3

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Sent without waiting. After \startpipeline, psql sends INSERT one and INSERT two straight after each other. It does not wait for the first answer before sending the second, which saves a round trip per statement.
  2. A sync point. \syncpipeline sends a sync. The two inserts since the start form one implicit transaction, which the server commits at the sync.
  3. The third insert. INSERT three … RETURNING id goes out after the sync, in a new implicit transaction, and \endpipeline sends the final sync and reads every result.
  4. All the answers. psql prints the results in order, INSERT 0 1 twice, then the returned id 3 and its INSERT 0 1. The table holds one, two and three.
  5. A pipeline with an error. The second pipeline sends INSERT four, SELECT 1 / 0 and INSERT five with no sync between them, so all three belong to one implicit transaction.
  6. The error aborts its part. four is inserted, then 1 / 0 fails with division by zero. The server rolls back that implicit transaction, four included, and skips five until the sync.
  7. What stayed. The final SELECT shows one, two and three. Put a sync after every unit of work that must stand on its own, because an error takes back everything since the last one.

A pipeline’s statements between two syncs run as one implicit transaction: A transaction the server opens by itself for statements sent without BEGIN: one statement on its own, or everything between two sync points of a pipeline. An error in it rolls back all of it.. In the second pipeline four was inserted, SELECT 1 / 0 failed, the server rolled back that implicit transaction and skipped five; the final SELECT showed rows 1 to 3 only. libpq’s chapter on pipeline mode describes exactly this, and it is the behaviour your application’s driver has too when it pipelines.

Changed in psql 18, and coming in 19

psql 18 added \parse, \bind_named, \close_prepared and the pipeline commands, and the prompt escape %P that shows the pipeline’s state. The release notes of PostgreSQL 19, still in preparation, add the prompt escapes %S for the search path and %i for whether the server is a hot standby.

Checkpoint

Check: Prepared statements and pipelines

  1. Question 1 of 2 A pipeline sends INSERT four, SELECT 1 / 0 and INSERT five with no sync between them. Which of four and five are in the table afterwards?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Neither: the error rolled back four and five was skipped

    Put \syncpipeline after every unit of work that must stand on its own. The first pipeline's sync committed one and two before three was sent.

  2. Question 2 of 2 What did \parse orders_of followed by \bind_named orders_of 1 \g do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Prepared the query once as orders_of, then ran it with $1 = 1

    The run executed orders_of for customers 1 and 2, listed it in pg_prepared_statements, and \close_prepared dropped it, so the count fell to 0.

Practise: write the script you will need at three in the morning

Before the self-check, write down how you would make a script of your own stop at its first error, run every statement in one transaction, take a table name from a variable safely, and generate one statement per table from the catalogs. Every question below comes back to one of those habits:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Which command lists a database's indexes with their sizes?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: \di+

    The recorded \di+ listed four indexes of the shop database, from 8192 bytes to 88 kB.

  2. Question 2 of 10 You want to write a monitoring query that lists what \d orders shows, without psql. What is the quickest way to start?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: Run psql -E and \d orders, which prints the catalog queries psql runs, then adapt them

    psql -E (or \set ECHO_HIDDEN on) prints each catalog query before its result, a good way to learn pg_class, pg_attribute, pg_index and pg_constraint.

  3. Question 3 of 10 A script has \set city 'Pune'. Which line puts the value into a query as a correctly quoted SQL string?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: WHERE city = :'city'

    :'name' for literals and :"name" for identifiers. The recorded report found Asha Rao for Pune.

  4. Question 4 of 10 A migration script of 12 statements must apply completely or not at all. Which psql command line does that?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: psql -X --single-transaction -v ON_ERROR_STOP=on -f migrate.sql

    Stopping and rolling back together guarantee all or nothing. -X keeps a personal ~/.psqlrc from changing the script's behaviour.

  5. Question 5 of 10 How does \gexec treat a result with two columns per row?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: It runs each field as a statement, left to right within each row, and skips NULL fields

    A generated query cannot be a psql meta-command or use psql variables: it goes to the server as it is.

  6. Question 6 of 10 One of 50 statements generated by \gexec fails. What happens to the other 49 without ON_ERROR_STOP?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: They still run, so a script must set ON_ERROR_STOP if a failure should stop it

    Decide before you run it: maintenance such as ANALYZE may continue past one failure, a migration may not.

  7. Question 7 of 10 An administrator runs COPY sales FROM 'sales.csv' as a superuser. Where does the server look for the file?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Relative to the server process's working directory, normally the data directory

    Use absolute paths with server-side COPY, or \copy for files that are on your side.

  8. Question 8 of 10 A test script feeds in-line rows to a COPY FROM STDIN that is meant to fail because its table is missing. What changed in psql 18.6, and what must the script contain?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: psql now skips the in-line data of a COPY that failed at the start, so the data must end with a \. line

    Keep psql up to date: security fixes in client programs ship in minor releases such as 18.6.

  9. Question 9 of 10 What does a pipeline save, compared with sending the same statements one by one?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: The wait for each answer before the next statement is sent, so network round trips overlap

    Pipelining matters most when the client is far from the server: many statements, one round trip of waiting.

  10. Question 10 of 10 An importer pipelines 1,000 independent inserts and wants a bad row to cost only that row. Where do the syncs go?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: After every insert, so each one is its own implicit transaction and an error rolls back only that one

    Group statements between syncs by what must succeed together. Inserts that stand alone get a sync each; a batch that must be all or nothing shares one.

SQL Formatter Lay out a long catalog query before you turn it into a \gexec script.

Interview questions

Warm-up (fresher): What is the difference between COPY and \copy, and when do you use each?

Both move rows between a table and a file, in the same formats. SQL’s COPY runs on the server, so a file name in it is opened by the server process, on the database server’s machine, with the server’s operating-system permissions; that is why COPY to or from a file needs superuser or membership in pg_read_server_files or pg_write_server_files. psql’s \copy runs COPY … FROM STDIN or TO STDOUT and reads or writes the file itself, on the machine where psql runs, with your own permissions, so any role with the right table privileges can use it. Use \copy for files on your own machine or a jump host, and COPY with a file name only for files that are already on the database server, in an administrative job.

Two questions go further: a nightly script that ran past its first error, and a \gexec script that broke on an unusual table name. This module’s question page gives the first move for each and the follow-ups to expect:

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

  1. Fresher to mid

    A nightly psql script of 40 statements failed at statement 7, ran the other 33, exited with status 0 and left the data half migrated. The scheduler reported success. How do you make the script safe, and how does the scheduler learn that it failed?

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

  2. Mid to senior

    A \gexec script grants SELECT on every table of a schema with 'GRANT SELECT ON ' || schemaname || '.' || relname || ' TO reporting'. It broke on a table called Order Items, and a colleague warns it is unsafe as well. Explain both problems and rewrite the query.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What are psql's \d commands, really?

    Catalog queries psql writes for you: \dt+ and \di+ add sizes, \d name shows an object in full, \x auto switches wide results to one block per row, and psql -E prints the query behind each command.

  2. How do psql variables behave in a script?

    They are text psql substitutes: :'name' inserts a safely quoted literal, \gset stores a query's single row in variables, and \if needs a true/false word because psql does no arithmetic — let the server compare.

  3. How do you make a psql script stop at its first error or run all-or-nothing?

    \set ON_ERROR_STOP on stops at the first error with exit status 3. --single-transaction wraps the whole file in BEGIN/COMMIT and rolls back on the first error.

  4. What does \gexec do, and how do you keep it safe?

    It runs each field of a query's result as its own statement — handy with the catalogs for maintenance scripts. Use format('%I') for identifiers and %L for literals so unusual names stay correct.

  5. What is the difference between \copy and COPY with a file name?

    \copy reads/writes the file where psql runs, with your permissions. COPY with a file name opens it on the server as the server's OS user and needs superuser or pg_read_server_files / pg_write_server_files.

  6. In a psql 18 pipeline, how are statements grouped into transactions?

    Statements between two syncs run as one implicit transaction, so an error rolls back everything since the last sync. psql 18 added \parse, \bind_named, \close_prepared and the pipeline commands to mimic drivers.

Key takeaways

  • The \d commands are catalog queries psql writes for you: \dt+ and \di+ add sizes, \d name shows an object in full, \x auto switches wide results to one block per row, and psql -E shows the query behind each command.
  • psql variables are text that psql substitutes: :'name' quotes a literal safely, \gset stores a row, and \if needs a true or false value, so let the server do comparisons.
  • \set ON_ERROR_STOP on stops a script at its first error with exit status 3, and --single-transaction makes the whole file one transaction that rolls back on an error.
  • \gexec runs each field of a query’s result as a statement, and format('%I') keeps generated identifiers safe.
  • \copy reads and writes files where psql runs, with your permissions, while COPY with a file name runs on the server and needs pg_read_server_files or superuser.
  • psql 18’s \parse, \bind_named and pipeline commands test what drivers do; statements between two syncs form one implicit transaction, so an error rolls back everything since the last sync.

Terms in this lesson

implicit transaction
A transaction the server opens by itself for statements sent without BEGIN: one statement on its own, or everything between two sync points of a pipeline. An error in it rolls back all of it.
meta-command
A psql command that starts with a backslash, such as \d, \copy or \gexec. psql handles it itself instead of sending it to the server, often by running catalog queries for you.
prepared statement
A statement the server has parsed and stored under a name for one session, so that it can be run many times with different parameter values ($1, $2 …) without being parsed again.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.