Data model¶
This page describes the database Pumperly stores its data in: the two tables, their columns and indexes, and what the external_id and source columns mean. It is for contributors writing scrapers and for operators who query their own database.
Pumperly uses PostgreSQL with the PostGIS extension. PostGIS adds geographic types and functions, so the database itself can answer "which stations are inside this box" or "within 5 km of this route". Everything Pumperly shows lives in two tables:
stations: one row per fuel station or EV charger.fuel_prices: one row per price, pointing at a station.
erDiagram
stations ||--o{ fuel_prices : "has prices"
stations {
uuid id PK
text external_id
varchar country
text name
text brand
text address
text city
text province
varchar station_type
geometry geom
smallint max_power_kw
timestamptz created_at
timestamptz updated_at
}
fuel_prices {
bigint id PK
uuid station_id FK
varchar fuel_type
decimal price
varchar currency
timestamptz reported_at
varchar source
}
There are no user tables. Pumperly has no accounts, and the database holds nothing about visitors.
Where the schema is defined¶
Two places in the repository describe these tables, and they are not identical.
prisma/schema.prismais the Prisma schema. The Prisma client used by the app is generated from it.prisma/migrations/holds the SQL that creates the tables. It includes what the Prisma schema language cannot express.
| Item | schema.prisma |
Migrations |
|---|---|---|
stations.geom |
Not declared | geometry(Point, 4326) |
The two GiST indexes on geom |
Not declared | Created |
Default for stations.id |
@default(uuid()), filled in by the Prisma client |
DEFAULT gen_random_uuid(), filled in by the database |
fuel_prices.price |
Decimal(10, 3) |
DECIMAL(6,3) in the first migration, widened to DECIMAL(10,3) by a later one |
Scrapers write with raw SQL
Scrapers insert stations and prices with SQL, not through the Prisma client. Their station insert sets geom and leaves id to the database default. A database therefore needs the geom column and the id default from the migrations, or every scraper insert fails. The map and route queries also rely on the GiST indexes to stay fast. Tools that build the tables from schema.prisma alone, such as prisma db push, create none of these.
The shipped files, included here word for word:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
extensions = [postgis]
}
model Station {
id String @id @default(uuid()) @db.Uuid
externalId String @map("external_id")
country String @db.VarChar(2)
name String
brand String?
address String
city String
province String?
stationType String @default("fuel") @map("station_type") @db.VarChar(20)
/// Highest single-connector power (kW) for EV chargers; null when unknown or not a charger.
maxPowerKw Int? @map("max_power_kw") @db.SmallInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
prices FuelPrice[]
@@unique([externalId, country])
@@map("stations")
}
model FuelPrice {
id BigInt @id @default(autoincrement())
stationId String @map("station_id") @db.Uuid
fuelType String @map("fuel_type") @db.VarChar(20)
price Decimal @db.Decimal(10, 3)
currency String @default("EUR") @db.VarChar(3)
reportedAt DateTime @default(now()) @map("reported_at") @db.Timestamptz
source String @db.VarChar(50)
station Station @relation(fields: [stationId], references: [id], onDelete: Cascade)
@@index([stationId, fuelType, reportedAt(sort: Desc)])
@@map("fuel_prices")
}
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateTable
CREATE TABLE "stations" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"external_id" TEXT NOT NULL,
"country" VARCHAR(2) NOT NULL,
"name" TEXT NOT NULL,
"brand" TEXT,
"address" TEXT NOT NULL,
"city" TEXT NOT NULL,
"province" TEXT,
"station_type" VARCHAR(20) NOT NULL DEFAULT 'fuel',
"geom" geometry(Point, 4326),
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fuel_prices" (
"id" BIGSERIAL NOT NULL,
"station_id" UUID NOT NULL,
"fuel_type" VARCHAR(20) NOT NULL,
"price" DECIMAL(6,3) NOT NULL,
"currency" VARCHAR(3) NOT NULL DEFAULT 'EUR',
"reported_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"source" VARCHAR(50) NOT NULL,
CONSTRAINT "fuel_prices_pkey" PRIMARY KEY ("id")
);
-- CreateIndex: unique station per country source
CREATE UNIQUE INDEX "stations_external_id_country_key" ON "stations"("external_id", "country");
-- CreateIndex: spatial GiST on geometry
CREATE INDEX "stations_geom_idx" ON "stations" USING GIST ("geom");
-- CreateIndex: fast price lookups
CREATE INDEX "fuel_prices_station_id_fuel_type_reported_at_idx" ON "fuel_prices"("station_id", "fuel_type", "reported_at" DESC);
-- AddForeignKey
ALTER TABLE "fuel_prices" ADD CONSTRAINT "fuel_prices_station_id_fkey" FOREIGN KEY ("station_id") REFERENCES "stations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Functional GiST so ST_DWithin(geom::geography, …) is index-backed.
-- NOTE: CONCURRENTLY cannot run inside Prisma's migration transaction.
-- On production (~145K rows) apply this out-of-band via psql to avoid locking writes:
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS stations_geom_geography_idx ON stations USING GIST ((geom::geography));
CREATE INDEX IF NOT EXISTS stations_geom_geography_idx ON stations USING GIST ((geom::geography));
-- 0_init created fuel_prices.price as DECIMAL(6,3), which cannot hold 1,000 or
-- more. schema.prisma has always declared DECIMAL(10,3), and the per-currency
-- price bands in src/scrapers/base.ts accept values up to 20,000 (ARS) and
-- 2,000 (HUF), so a database built from the migrations rejected those rows.
-- Databases created with `prisma db push` already have DECIMAL(10,3); running
-- this on them changes nothing.
ALTER TABLE "fuel_prices" ALTER COLUMN "price" TYPE DECIMAL(10,3);
-- Highest single-connector power (kW) for EV chargers, filled by the OCM, REVE
-- and BNetzA scrapers. Nullable: fuel stations and chargers with no published
-- power keep NULL. ADD COLUMN with no default is metadata-only, so it is
-- instant even on a full table.
ALTER TABLE "stations" ADD COLUMN IF NOT EXISTS "max_power_kw" SMALLINT;
stations¶
One row per place: a fuel station, an EV charger, or a site that is both.
| Column | Type | Null | Meaning |
|---|---|---|---|
id |
uuid |
No | Pumperly's own id for the row. Generated by the database. Prices point at it. |
external_id |
text |
No | The source's id for the station. See external_id. |
country |
varchar(2) |
No | ISO 3166-1 alpha-2 code of the scraper that wrote the row. It is not worked out from the coordinates. |
name |
text |
No | Display name. Scrapers build one when the source has none, for example from the brand and the town. |
brand |
text |
Yes | Brand or network, such as a fuel company or a charge point operator. null when the source gives none. |
address |
text |
No | Street address. An empty string when the source has none. |
city |
text |
No | Town or municipality. An empty string when the source has none. |
province |
text |
Yes | Region, state or county, when the source gives one. |
station_type |
varchar(20) |
No | fuel, ev_charger or both. Defaults to fuel. |
geom |
geometry(Point, 4326) |
Yes | The position, as a PostGIS point in WGS84 longitude and latitude. Scrapers always set it. |
max_power_kw |
smallint |
Yes | EV chargers: highest single-connector power in kW, set by the BNetzA, OCM and REVE scrapers. null for fuel stations and chargers with no published power. |
created_at |
timestamptz |
No | When the row was first inserted. Never changed afterwards. |
updated_at |
timestamptz |
No | When a scraper run last wrote this station. Every successful upsert sets it to the current time, even when nothing changed. |
station_type decides how the app queries a station:
| Value | Written by | Shown for |
|---|---|---|
fuel |
Fuel price scrapers | Fuel searches, joined to fuel_prices |
ev_charger |
OpenChargeMap, Mapa REVE and BNetzA scrapers | The EV filter, without prices |
both |
Accepted by the scraper contract. No built-in scraper writes it. | The EV filter, and fuel searches when it has prices |
geom stores longitude first, as PostGIS does. To read it back as numbers, use ST_X(geom) for longitude and ST_Y(geom) for latitude.
fuel_prices¶
One row per price of one fuel at one station.
| Column | Type | Null | Meaning |
|---|---|---|---|
id |
bigint |
No | Row id, from a sequence. |
station_id |
uuid |
No | The station, stations.id. Deleting a station deletes its prices. |
fuel_type |
varchar(20) |
No | A fuel code such as E5, E10 or B7. See Fuel types. The database does not check the value. The RawFuelPrice type limits scrapers to codes from the list when the code is compiled. |
price |
decimal |
No | Price per litre in currency, with three decimals. Gas fuels sold by weight, such as CNG in some countries, are per kilogram. |
currency |
varchar(3) |
No | ISO 4217 code of price, such as EUR or SEK. Defaults to EUR. Scrapers always set it. |
reported_at |
timestamptz |
No | When Pumperly wrote the price, not when the source last changed it. |
source |
varchar(50) |
No | The name of the scraper source that wrote the row. See source. |
EV is a valid fuel code for API requests, but no price row ever carries it. An EV search queries stations by station_type instead. EV chargers have no rows in this table.
Nothing stops two rows for the same station and fuel. Readers take the newest one by reported_at. The example query below shows the pattern.
Indexes and constraints¶
| Name | On | Used for |
|---|---|---|
stations_pkey |
stations (id) |
Primary key. |
stations_external_id_country_key |
stations (external_id, country), unique |
The conflict target of the scrapers' INSERT ... ON CONFLICT. It makes a station's identity the pair of its source id and its country. |
stations_geom_idx |
stations USING GIST (geom) |
Bounding-box searches on the map (ST_Within an envelope), radius searches for the nearest stations, and the fast && pre-filter of the route search. |
stations_geom_geography_idx |
stations USING GIST ((geom::geography)) |
Distance in metres along a route (ST_DWithin on geography). |
fuel_prices_pkey |
fuel_prices (id) |
Primary key. |
fuel_prices_station_id_fuel_type_reported_at_idx |
fuel_prices (station_id, fuel_type, reported_at DESC) |
Finding the newest price of one fuel at one station. |
fuel_prices_station_id_fkey |
fuel_prices.station_id references stations.id |
ON DELETE CASCADE and ON UPDATE CASCADE: a deleted station takes its prices with it. |
A GiST index is PostgreSQL's index type for geometric data. It lets a spatial query skip rows far from the area it asks about. The second GiST index is built on the expression geom::geography, so a query written with that exact cast can use it. The migration notes that on a large existing table you may prefer to build it by hand with CREATE INDEX CONCURRENTLY, which does not block writes.
external_id¶
external_id is the station's id in its upstream source. It is how a scraper recognises a station it wrote on an earlier run. Each run upserts on (external_id, country): a known pair updates the existing row, a new pair inserts a new one.
That makes two properties essential:
- Stable. The same station gets the same
external_idon every run. If it changes, a new row with a newidis inserted. For a fuel station, the old row loses its prices and the orphan cleanup deletes it. An old EV charger row stays unless its scraper removes it. - Unique within the country, across all sources. The unique index covers
external_idandcountryonly, not the source. After the station upsert, a run also maps its prices to stations byexternal_idwithin the country. Two sources that used the same id in one country would share, and overwrite, one row.
To keep sources apart, scrapers prefix the upstream id with a short tag. Some examples from the current scrapers:
| Source | external_id form |
|---|---|
bensinpriser (Sweden) |
se-bp-<station id> |
nsw_fuelcheck (New South Wales) |
nsw_<station code> |
ocm (OpenChargeMap, every country) |
ocm-<POI id> |
reve (Mapa REVE, Spain) |
reve-<location id> |
bnetza (BNetzA register, Germany) |
bnetza-<latitude>_<longitude>, rounded to 5 decimals. The register has one row per charging device, and the scraper merges the rows at one position into one station. |
miteco (Spain) |
The ministry's station id, with no prefix |
Some older scrapers use the upstream id without a prefix, as miteco does. A new scraper should use a prefix. Adding a country gives the rules.
stations has no source column. For EV chargers, which have no prices, the external_id prefix is the only record of where a row came from. That is how the Mapa REVE and BNetzA scrapers find the OpenChargeMap rows they replace: external_id LIKE 'ocm-%'.
source¶
source is the name of the scraper that wrote a price row, such as miteco, cma, fuelo_pl or bensinpriser. It lives on fuel_prices only.
Every scraper run replaces exactly the price rows that carry its own source for stations in its own country. It never touches another source's rows. Two consequences:
- One source name can serve several countries. The Netherlands, Belgium and Luxembourg scrapers all write
anwb, and none of them deletes the others' prices, because the replace is limited to one country. - When a source is retired, its rows stay until something deletes them. The replacement scraper has to do that itself. How scrapers work shows the pattern.
How rows change over time¶
Only scrapers write to these tables. The app's API routes only read them.
| Event | Effect on stations |
Effect on fuel_prices |
|---|---|---|
| A source lists a new station | Row inserted | Its prices inserted |
| A source lists a known station again | Name, brand, address, city, province, type and position overwritten, updated_at set to now |
This source's rows deleted and inserted fresh, with a new reported_at |
| A source stops listing a fuel station | Deleted at the end of the run, once no source has a price for it | Its rows from this source deleted |
| A source stops listing an EV charger | Kept, unless the EV scraper removes it itself | None |
A run ends in a Fatal error |
Unchanged, or partly updated if the error came after some station batches | Unchanged, unless the error came after the price replace committed |
| A station batch fails | The other batches are written. No station is deleted this run. | Replaced as usual for the stations the database knows |
The full sequence, including the guards that stop a bad run from deleting data, is in How scrapers work.
Prices are replaced, not appended. The database holds the current price per station, fuel and source, not a price history.
Reading the newest price¶
To query the data yourself, join each station to its newest price for one fuel. This is the pattern the app's own station endpoints use:
SELECT s.name, s.brand, s.city, fp.price, fp.currency, fp.reported_at
FROM stations s
JOIN LATERAL (
SELECT price, currency, reported_at
FROM fuel_prices
WHERE station_id = s.id
AND fuel_type = 'B7'
ORDER BY reported_at DESC NULLS LAST
LIMIT 1
) fp ON true
WHERE ST_DWithin(
s.geom::geography,
ST_SetSRID(ST_MakePoint(-3.70, 40.42), 4326)::geography,
5000
)
ORDER BY fp.price
LIMIT 10;
This lists the ten cheapest diesel prices within 5 km of a point. ST_MakePoint takes longitude first. The lateral subquery can use the fuel_prices index, and the ST_DWithin on geom::geography can use the geography index.
For the same data over HTTP, see the HTTP API.