Guide · Scraping practice
How to store price time series: schema, SQL and deduplication
Store scraped prices as an append-only table of observations: one row per item, market, currency, context and timestamp, never updated in place. Keep the price as an exact decimal next to its displayed currency and a status, make inserts idempotent with a natural key so retried batches cannot duplicate rows, and derive the current price, the changes and the lowest price of the last 30 days with queries rather than by overwriting data.
Reviewed 30 Oct 2026
The short answer
- Append-only: every fetch is a row, including failures, with a status column so a blocked fetch is never read as a price.
- Dimensions that change the price belong in the key: item, point of sale, currency and context such as stay dates, occupancy or postcode.
- Use an exact type for money. PostgreSQL's documentation recommends
numericfor monetary amounts and warns that floating-point types store approximations. - A natural primary key plus
INSERT … ON CONFLICT DO NOTHINGmakes ingest idempotent. - A BRIN index suits a large table whose timestamp grows with insertion order; partition by month once the table gets large.
- Convert currencies at query time with dated rates; store what the page showed.
Why append-only beats a current-price table
- History is the product. Trends, seasonality and “lowest price in 30 days” checks all need past values.
- Alerts become auditable. Every alert points at two stored observations.
- Bugs become recoverable. With a hash of each raw response and the parser version, you can re-parse a period after fixing a bug.
- Failures stay visible. A status column distinguishes out-of-stock, blocked and parse errors from real prices.
A table that holds only the current price answers one question and loses the evidence for every other one.
The observation model
| Column | Purpose | Example |
|---|---|---|
item_id | What is priced, via a separate item table keyed by source and the source's own ID | Hotel, SKU, route |
observed_at | When the page showed it, in UTC (timestamptz) | 2026-10-30 08:00:00+00 |
pos | Market or point of sale | DE |
currency | Currency as displayed | EUR |
context | Other inputs that change the price | checkin=2026-12-01;nights=2;adults=2 |
price | Exact amount; NULL when status is not ok | 129.00 |
tax_included | Whether the amount includes tax | true |
status | Outcome of the fetch | ok, out_of_stock, blocked, parse_error |
session_id | Run or proxy session that fetched it | k3f9a1x2 |
raw_sha256 | Hash of the raw response, for re-parsing | — |
The context column keeps one schema for many sources; if a dimension is queried constantly — check-in date for hotels, say — promote it to its own column. Why market and currency must be explicit is covered in comparing prices across markets and point of sale.
Exact amounts and currencies
PostgreSQL's documentation recommends numeric especially for monetary amounts and other quantities where exactness is required, and explains that real and double precision are inexact: some values are stored as approximations, so storing and retrieving can show small discrepancies. In application code, the equivalent is Python's Decimal, never float.
Store the currency the page displayed and the amount as displayed. Do not convert at ingest: exchange rates change, and a converted figure cannot be recomputed with a different rate rule. Put conversions in a view that joins dated reference rates. Record a tax_included flag as well, since the same product can be shown gross in one market and net in another.
Deduplication: idempotent inserts and unchanged prices
Two different things get called duplicates. The first is the same observation ingested twice — a retried batch, a queue that redelivers. Prevent it with a natural primary key over item, market, currency, context and observed_at, and insert with ON CONFLICT DO NOTHING; PostgreSQL's documentation describes that clause as avoiding the insert instead of raising a unique-violation error.
The second is the same price observed many times. Those rows are not duplicates — they prove the price was still valid at each check, which matters for freshness and for 30-day minimums. If storage becomes a concern, derive a compact table of price runs (item, market, price, valid_from, valid_to) for reporting, and keep the raw observations for a defined retention period.
Duplicate items — the same flat or hotel listed under two IDs or on two sites — are a separate problem; see deduplicating listings.
Indexes, partitions and retention
- The primary key doubles as the index for “history of this item in this market”.
- A BRIN index on `observed_at`. PostgreSQL describes BRIN as designed for very large tables where a column correlates with physical position — exactly what an append-only timestamp does. It is tiny compared with a B-tree.
- Monthly partitions by
observed_atonce the table is large: old months can be compressed, archived or dropped as a unit. - Retention. Keep raw responses for days or weeks, their hashes and the parsed rows for as long as the analysis needs.
The queries you will run most
- Current price per item and market:
DISTINCT ONthe key, ordered byobserved_at DESC, filtered tostatus = 'ok'. - Changes only: the window function
lag()over the key, keeping rows where the price differs from the previous successful one. - Lowest price in the 30 days before an observation: a correlated
min()over the preceding 30 days — the reference point for checking announced discounts, as described in travel price transparency rules.
All three are in the SQL below, tested on PostgreSQL 16. For a complete collector that feeds such a table, see how to build a flight price tracker; for running it across a catalogue, price monitoring.
Code
Worked example
-- What is being priced: one row per product/offer you track.CREATE TABLE item ( item_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source text NOT NULL, -- site or feed source_key text NOT NULL, -- the source's own id (SKU, hotel id, route) UNIQUE (source, source_key));-- Every observation, append-only. Never UPDATE a price.CREATE TABLE price_obs ( item_id bigint NOT NULL REFERENCES item, observed_at timestamptz NOT NULL, -- when the page said it (UTC) pos char(2) NOT NULL, -- point of sale / market, ISO 3166-1 alpha-2 currency char(3) NOT NULL, -- ISO 4217, as displayed context text NOT NULL DEFAULT '', -- e.g. 'checkin=2026-12-01;nights=2;adults=2' price numeric(12,2), -- NULL when status is not 'ok' tax_included boolean, status text NOT NULL, -- ok | out_of_stock | blocked | parse_error session_id text, -- proxy session / run that fetched it raw_sha256 bytea, -- hash of the raw response, for re-parsing ingested_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (item_id, pos, currency, context, observed_at));CREATE INDEX price_obs_observed_brin ON price_obs USING brin (observed_at);-- Idempotent ingest: a retried batch cannot create duplicates.INSERT INTO price_obs (item_id, observed_at, pos, currency, price, tax_included, status)VALUES (1, '2026-10-30 08:00:00+00', 'DE', 'EUR', 129.00, true, 'ok')ON CONFLICT DO NOTHING;-- Changes only: compare each ok observation with the previous ok one.SELECT item_id, pos, currency, context, observed_at, price, price - prev_price AS deltaFROM ( SELECT o.*, lag(price) OVER (PARTITION BY item_id, pos, currency, context ORDER BY observed_at) AS prev_price FROM price_obs o WHERE status = 'ok') tWHERE prev_price IS DISTINCT FROM price;-- Latest good price per item, market and context.SELECT DISTINCT ON (item_id, pos, currency, context) item_id, pos, currency, context, observed_at, priceFROM price_obsWHERE status = 'ok'ORDER BY item_id, pos, currency, context, observed_at DESC;-- Lowest price in the 30 days before each observation (checks "was" prices).SELECT o.item_id, o.pos, o.observed_at, o.price, (SELECT min(p.price) FROM price_obs p WHERE p.item_id = o.item_id AND p.pos = o.pos AND p.currency = o.currency AND p.context = o.context AND p.status = 'ok' AND p.observed_at >= o.observed_at - interval '30 days' AND p.observed_at < o.observed_at) AS lowest_prior_30dFROM price_obs oWHERE o.status = 'ok';FAQ
Questions
Something not covered? Write to contact@arcproxies.io.
Should I store prices as float or decimal?
Decimal. Floating-point types store many decimal values only approximately, so sums and comparisons can be off by small amounts. PostgreSQL recommends numeric for monetary amounts; in Python, use Decimal.
How do I avoid duplicate rows when a scraper retries?
Give the observation table a natural primary key — item, market, currency, context and observed_at — and insert with ON CONFLICT DO NOTHING. A retried batch then inserts nothing new.
Do I need a time-series database for price data?
Not to start. A relational database with an append-only table, a primary key, a BRIN index on the timestamp and monthly partitions handles a large volume of price observations. Consider specialised storage when your query patterns or volume outgrow that.
Should I store a new row when the price has not changed?
Yes, at least for a retention period. An unchanged observation proves the price was still valid at that time, which matters for freshness and for 30-day minimum checks. You can derive a compact table of price runs for reporting.
How should I store prices in different currencies?
Store each amount in the currency the page displayed, with an ISO 4217 code, and convert in queries using dated reference rates. Converting at ingest makes it impossible to recompute with a different rate rule.
How long should I keep raw HTML or JSON responses?
Long enough to re-parse after a parser bug is found — typically days or weeks. Keep a hash of each response and the parsed rows for as long as the analysis needs them.
Sources & review
Last reviewed by the ARC Proxies team. Prices are read from the live catalog at build time.
- [1]PostgreSQL: Numeric Types (arbitrary precision and floating-point)
- [2]PostgreSQL: BRIN Indexes
- [3]PostgreSQL: INSERT … ON CONFLICT
- [4]PostgreSQL: Window Functions
Next
Where to go from here
Guides