15% off your first purchase with code WELCOME15, up to 25 GB.

See pricing

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 numeric for monetary amounts and warns that floating-point types store approximations.
  • A natural primary key plus INSERT … ON CONFLICT DO NOTHING makes 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

ColumnPurposeExample
item_idWhat is priced, via a separate item table keyed by source and the source's own IDHotel, SKU, route
observed_atWhen the page showed it, in UTC (timestamptz)2026-10-30 08:00:00+00
posMarket or point of saleDE
currencyCurrency as displayedEUR
contextOther inputs that change the pricecheckin=2026-12-01;nights=2;adults=2
priceExact amount; NULL when status is not ok129.00
tax_includedWhether the amount includes taxtrue
statusOutcome of the fetchok, out_of_stock, blocked, parse_error
session_idRun or proxy session that fetched itk3f9a1x2
raw_sha256Hash 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_at once 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 ON the key, ordered by observed_at DESC, filtered to status = '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

prices.sql — PostgreSQL schema, idempotent insert and three standard queries
-- 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. [1]PostgreSQL: Numeric Types (arbitrary precision and floating-point)
  2. [2]PostgreSQL: BRIN Indexes
  3. [3]PostgreSQL: INSERT … ON CONFLICT
  4. [4]PostgreSQL: Window Functions

Next

Where to go from here

Use caseProxies for price and competitor monitoringSee the price a shopper in Berlin, Lyon or Chicago sees. ISP bandwidth at our lowest price per GB for HTML checks, residential where a marketplace is strict.Use caseProxies for alternative data in financeTurn public listings, pricing and hiring signals into a time series your models can trade on — collected on schedule, from the market you are modelling.Use caseProxies for price and competitor monitoringSee the price a shopper in Berlin, Lyon or Chicago sees. ISP bandwidth at our lowest price per GB for HTML checks, residential where a marketplace is strict.Deep diveOnline price indicesBuild daily price indices from public online prices for inflation nowcasting. Per-market exits, steady daily cadence, per-GB billing, no subscription.GuideHow to build a flight price trackerArchitecture of a flight price tracker: route-date matrix, sampling, sessions per search, storage, change detection and alerting, with a Python sketch.GuideComparing prices across marketsHow to compare prices across countries: the same product, a controlled point of sale, synchronised timing, reference-rate FX and equal tax treatment.GuideDeduplicating listingsFind duplicate listings across sites: normalisation, fingerprints, blocking, fuzzy matching with hard rules and clustering, for property, hotels and jobs.GlossaryPoint of sale (POS)Point of sale (POS), in travel, is the market in which a fare or rate is sold — usually the country of the seller or of the website edition the traveller uses — and airlines, hotels and other suppliers can set different prices, conditions and availability for each one.