Skip to main content

PostgreSQL for Journalists: A UK Guide

When a dataset outgrows the spreadsheet and even SQLite starts to strain, PostgreSQL gives you a proper database — with CTEs, window functions, indexing and mapping.

Last reviewed: Next review due:

1. What PostgreSQL is, and when you need it

PostgreSQL (“Postgres”) is a free, open-source relational database that runs as a server on your machine. It speaks standard SQL, like SQLite, but adds a serious query planner, rich indexing, spatial support and the ability for several people to query the same data at once. This guide assumes you know the SQL basics — SELECT, WHERE, GROUP BY, JOIN— and want to work at a larger scale.

Reach for Postgres when a file-based approach starts to hurt: tens of millions of rows, geographic analysis with PostGIS, or a database that will power a live page. If your project fits in one portable SQLite file, stay there. Download Postgres from postgresql.org.

2. Installing Postgres locally

On a Mac, Postgres.app (postgresapp.com) is the quickest start. On Windows or Linux use the official installers from postgresql.org. If you use Docker, one command gives you a disposable server:

# a throwaway Postgres in Docker
docker run --name pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres

# create a database, then connect with the psql client
createdb investigations
psql investigations

Inside psql, the meta-commands \dt lists tables and \d tablenamedescribes one. Pair the server with a GUI — DBeaver or pgAdmin — while you find your feet.

3. Loading a CSV with \copy

First define a table whose columns and types match your file, then stream the CSV in. The client-side \copy reads a file from your computer, which is what journalists usually want.

CREATE TABLE price_paid (
  transaction_id text,
  price          integer,
  date_of_transfer date,
  postcode       text,
  town_city      text,
  county         text
);

-- run inside psql: load a CSV that has a header row
\copy price_paid FROM 'price-paid-2024.csv' WITH (FORMAT csv, HEADER true);

If rows fail on a type mismatch, load everything into an all-text staging table first, clean it in SQL, then cast into the typed table. Always run SELECT count(*) afterwards to confirm the total matches the source file.

4. Queries: WHERE, GROUP BY and JOIN

The everyday SQL you know works identically here, just faster on big tables. This aggregates average price by town and joins in a reference table of regions.

SELECT r.region,
       p.town_city,
       count(*)                 AS sales,
       round(avg(p.price))      AS avg_price
FROM price_paid p
JOIN regions r ON r.town_city = p.town_city
WHERE p.date_of_transfer >= '2024-01-01'
GROUP BY r.region, p.town_city
HAVING count(*) > 50
ORDER BY avg_price DESC;

5. Common table expressions (CTEs)

A WITH clause names a query so you can build an analysis in readable steps rather than nested subqueries. Here we total consultancy spend per authority, then keep those above the overall average.

WITH by_authority AS (
  SELECT authority, sum(amount) AS total
  FROM council_spending
  WHERE expense_type ILIKE '%consultancy%'
  GROUP BY authority
)
SELECT authority, total
FROM by_authority
WHERE total > (SELECT avg(total) FROM by_authority)
ORDER BY total DESC;

ILIKEis a Postgres convenience for case-insensitive matching — handy when suppliers spell categories inconsistently.

6. Window functions

Window functions calculate across related rows without collapsing them, so you can rank or run totals while keeping every line. This finds the three largest payments within each authority.

SELECT authority, supplier, amount
FROM (
  SELECT authority, supplier, amount,
         rank() OVER (PARTITION BY authority ORDER BY amount DESC) AS r
  FROM council_spending
) ranked
WHERE r <= 3
ORDER BY authority, amount DESC;

PARTITION BY restarts the ranking for each authority; swap rank() for row_number() or sum() OVER (...) for cumulative totals.

7. Indexing for speed

An index lets Postgres find rows without scanning the whole table. Add one on the columns you filter or join on most, and use EXPLAIN ANALYZE to check the planner is actually using it.

CREATE INDEX idx_price_town ON price_paid (town_city);
CREATE INDEX idx_price_date ON price_paid (date_of_transfer);

-- see whether a query uses the index or scans the table
EXPLAIN ANALYZE
SELECT * FROM price_paid WHERE town_city = 'LEEDS';

Indexes speed up reads but slow down bulk loads, so create them after your initial \copy, not before.

8. Spatial data with PostGIS (a taste)

PostGIS is an extension that teaches Postgres about geometry. Enable it with one command, then you can ask spatial questions — for example, which properties fall inside a given ward boundary.

CREATE EXTENSION postgis;

-- points (properties) that sit inside a ward polygon
SELECT p.postcode, w.ward_name
FROM properties p
JOIN wards w ON ST_Contains(w.geom, p.geom);

That is only a taste: PostGIS covers distance, area, buffers and reprojection. For a single map, QGIS may be faster; for repeatable spatial joins at scale, PostGIS earns its keep.

9. Working through a GUI: DBeaver and pgAdmin

  • 1DBeaver (dbeaver.io) is a free cross-platform client that connects to Postgres and many other databases; good for browsing tables and running ad-hoc queries.
  • 2pgAdmin (pgadmin.org) is the official Postgres GUI, with a server dashboard, query tool and visual table editors.
  • 3Both let you inspect schemas, view a table's contents and export results to CSV without touching the command line.
  • 4Keep psql handy anyway: \copy, \dt and \d are quicker than clicking once you know them.
  • 5Save your queries in .sql files under version control so the analysis stays reproducible.

10. Red flags

  • Loading a CSV into all-text columns and forgetting to cast, so numbers sort alphabetically and dates will not compare.
  • Confusing server-side COPY (needs the file on the database host) with client-side \copy (reads your local file).
  • Creating indexes before a big bulk load, which slows the load for no benefit.
  • Running DELETE or UPDATE without a WHERE clause, or without a backup, on the live table.
  • Assuming a query is fast because it worked on a sample; test EXPLAIN ANALYZE on the full table.

11. Migration checklist

  • I have installed Postgres (Postgres.app, an official installer, or Docker) and created a database.
  • I have defined table columns with the correct types, or staged as text and cast afterwards.
  • I have loaded with \copy and confirmed the row count with SELECT count(*).
  • I have added indexes on the columns I filter and join on, after the initial load.
  • I have checked slow queries with EXPLAIN ANALYZE.
  • I have saved every query in a .sql file under version control.

Get the data

Bulk UK datasets — Land Registry price paid, Companies House, ONS — are exactly the scale Postgres was built for. Browse sources, or file an FOI when the data is not published.

Related guides

Frequently asked questions

When does a journalist actually need PostgreSQL rather than SQLite?
SQLite is perfect for single-file, single-user analysis and handles millions of rows happily, so reach for Postgres when you outgrow it in specific ways. The usual triggers are datasets in the tens or hundreds of millions of rows where you need proper indexing and query planning; several people querying the same database at once; geographic analysis that needs PostGIS; or a database that will back a live web page. If none of those apply, SQLite keeps your project in one portable file. Postgres is a server you run and maintain, so only take on that overhead when the work justifies it.
What is the easiest way to install PostgreSQL?
On a Mac, Postgres.app from postgresapp.com is the simplest route: download, drag to Applications, and you have a working server with a click. On Windows or Linux, the official installer and packages from postgresql.org are straightforward. If you already use Docker, one command spins up a throwaway server that you can delete afterwards, which keeps your machine clean. Whichever you choose, pair it with a graphical client such as DBeaver or pgAdmin so you can browse tables and run queries visually while you learn the command line at your own pace.
How do I load a large CSV into PostgreSQL?
First create a table whose columns and types match the file, then use the copy command inside psql to stream the file in. The client-side copy reads a file from your own computer and is the tool most journalists want, because it does not require the file to sit on the database server. Give it the path, tell it the format is CSV and whether there is a header row, and Postgres loads millions of rows in seconds. If a row fails on a type mismatch, load into an all-text staging table first, clean it in SQL, then cast into the typed table.
What are CTEs and window functions, and why do they matter?
A common table expression, written with WITH, names a query so you can build an analysis in readable steps instead of deeply nested subqueries. Window functions calculate across a set of rows related to the current row without collapsing them, so you can rank, run totals or compare each row to its group. Together they turn awkward multi-step questions into clear SQL: rank suppliers within each council, find the top three payments per department, or compute a running cumulative spend. Both are standard SQL, but Postgres implements them fully and fast, which is a big reason to move up from a spreadsheet.
Do I need PostGIS for mapping, and is it hard?
If your story is about where things are -- which properties fall inside a flood zone, which addresses sit in a given ward -- PostGIS is worth the setup. It is an extension you enable with one command, after which Postgres understands geometry columns and spatial functions such as testing whether a point lies inside a boundary. You load boundary files, index the geometry, and ask spatial questions in SQL. It is more involved than ordinary queries, so most reporters learn it once they have a concrete mapping need. For a one-off map, desktop tools like QGIS may be quicker; for repeatable spatial joins at scale, PostGIS wins.