station_id | station_name | state | date | precip | storm_name | storm_year-----------+--------------+-------+------------+--------+------------+-----------USW... | PITTSBURGH | PA | 2025-09-01 | 12.4 | ERIN | 2025USW... | PITTSBURGH | PA | 2025-09-02 | 2.1 | ERIN | 2025
Station and storm facts repeat whenever an observation repeats.
Repetition creates contradictions
If a station name appears in 10,000 rows:
which row is authoritative?
must every spelling be updated together?
can two rows disagree about the state?
what happens when a station has no observation yet?
The storage design should prevent these questions from becoming data-cleaning emergencies.
Checkpoint 1: find the entities
Split the giant flat file into tables. For each table, state what one row represents and choose a candidate key.
Start with four nouns:
station observation storm track point
Checkpoint 1: one defensible split
Table
One row
Candidate key
stations
one station
station_id
observations
one station-date
(station_id, date)
storms
one named storm-season
storm_id
storm_tracks
one storm timestamp
(storm_id, datetime)
Different schemas can work if their grain and rules are explicit.
One fact should have one home
Station name and elevation belong in stations.
Daily precipitation belongs in observations.
Storm name and season belong in storms.
Wind and location at a moment belong in storm_tracks.
This is the practical core of normalization: reduce redundancy while preserving relationships.
Relational design starts before data
A schema specifies:
tables,
columns,
data types,
keys and relationships,
constraints on valid values.
PostgreSQL then rejects rows that violate the design.
Types encode allowed operations
Meaning
PostgreSQL type
station identifier
TEXT
observation date
DATE
track timestamp
TIMESTAMPTZ
latitude/longitude
DOUBLE PRECISION
count
INTEGER
yes/no flag
BOOLEAN
Do not store every incoming field as text merely because the source file is text.
Missing values require a decision
NULL can mean “unknown,” “not observed,” or “not applicable.”
Ask for every column:
Is missing allowed?
Does the source use a sentinel such as -9999?
Should a missing value reject the row or remain NULL?
A type alone cannot answer these questions.
Primary keys identify rows
CREATETABLE stations ( station_id TEXT PRIMARYKEY, name TEXT NOTNULL, state CHAR(2), lat DOUBLEPRECISIONNOTNULL, lon DOUBLEPRECISIONNOTNULL, elevation NUMERIC(7, 2));
station_id must be unique and non-missing.
Constraints turn assumptions into rules
CHECK (lat BETWEEN-90AND90),CHECK (lon BETWEEN-180AND180),CHECK (state ISNULLOR state ~ '^[A-Z]{2}$')
Without a constraint, “latitude is valid” is only documentation.
With a constraint, invalid data cannot enter unnoticed.
Foreign keys protect relationships
CREATETABLE observations ( obs_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARYKEY, station_id TEXT NOTNULLREFERENCES stations(station_id),dateDATENOTNULL, tmax NUMERIC, tmin NUMERIC, precip NUMERIC);
An observation cannot reference a station that does not exist.
A surrogate key does not define the grain
The identity column generates an obs_id that uniquely identifies the stored row.