7 SQL JOIN øvelser der får tabellerne til at give mening

Forestil dig, at du sidder med to regneark åbne. I det ene står der kunder, i det andet ordrer. Din chef spørger: “Kan du lige lave en liste over kunder og deres seneste ordre?”. Du stivner lidt, fordi du godt ved, at svaret er JOIN, men det roder hurtigt.

Den situation er præcis det, JOIN er bygget til. I SQL er JOIN bare måden, du syr tabellerne sammen på. I den her artikel får du et fast datasæt, en håndfuld øvelser og forklaringer, så JOIN ikke længere føles som sort magi.

Hvorfor JOIN overhovedet findes

Jeg starter altid med det samme spørgsmål: hvorfor har vi flere tabeller i stedet for én stor?

Forestil dig en webshop, hvor du smider alt i én tabel: kunder, ordrer, produkter, alt. Det er hurtigt kaos. Du duplikerer de samme kundeoplysninger igen og igen, fejl sniger sig ind, og opdateringer bliver noget rod.

I stedet deler man tingene op:

  • En tabel med kunder
  • En tabel med ordrer
  • En tabel med produkter

Hver tabel har sin egen opgave, og de hænger sammen via ID’er. JOIN er bare det sprog, du bruger til at bede databasen om at sætte brikkerne sammen igen: “giv mig rækker, hvor det her ID matcher det der ID”.

Den mentale model jeg selv bruger, er ret lavpraktisk: to lister på bordet, og du sidder og tegner streger mellem linjer, hvor værdierne matcher. INNER JOIN er de linjer, hvor der er en streg i begge ender. LEFT JOIN er alle fra venstre liste, også dem uden en streg.

Datasæt du kan bruge til alle øvelserne

Vi kører med tre tabeller: customers, orders og products. Det er et klassisk setup, som minder meget om det man møder, når man begynder på backend eller små API’er.

Her er SQL’en til at oprette tabellerne:

CREATE TABLE customers (
  customer_id INT PRIMARY KEY,
  name        VARCHAR(100),
  city        VARCHAR(100)
);

CREATE TABLE products (
  product_id INT PRIMARY KEY,
  name       VARCHAR(100),
  price      DECIMAL(10,2)
);

CREATE TABLE orders (
  order_id    INT PRIMARY KEY,
  customer_id INT,
  product_id  INT,
  quantity    INT,
  order_date  DATE
);

Og lidt testdata:

INSERT INTO customers (customer_id, name, city) VALUES
  (1, 'Anna',   'Aarhus'),
  (2, 'Bo',     'Odense'),
  (3, 'Carla',  'Aarhus'),
  (4, 'Dennis', 'Aalborg');

INSERT INTO products (product_id, name, price) VALUES
  (10, 'Mus',        149.00),
  (11, 'Tastatur',   349.00),
  (12, 'Skærm',     1299.00);

INSERT INTO orders (order_id, customer_id, product_id, quantity, order_date) VALUES
  (100, 1, 10,  1, '2024-03-01'),
  (101, 1, 12,  2, '2024-03-05'),
  (102, 2, 11,  1, '2024-03-06'),
  (103, 2, 12,  1, '2024-03-10'),
  (104, 3, 10,  3, '2024-03-11');

Bevidst ingen ordrer på Dennis (customer_id 4). Det giver os nogle gode eksempler senere, især når vi snakker LEFT JOIN.

Hvis du vil gemme det et sted, så læg det i en lille lokal database. SQLite er fin til det. På Coding Class har vi i øvrigt flere introartikler til SQL, hvis du er helt ny.

INNER JOIN forklaret med et simpelt billede

INNER JOIN er standarden. “Giv mig de rækker, hvor der er et match i begge tabeller”.

Tænk på customers som venstre tabel og orders som højre tabel. Vi vil koble dem på customer_id, for det er det, de har til fælles.

Først et helt basalt eksempel:

SELECT
  customers.customer_id,
  customers.name,
  orders.order_id,
  orders.order_date
FROM customers
INNER JOIN orders
  ON customers.customer_id = orders.customer_id;

Mentalt billede: for hver kunde i customers kigger databasen i orders og finder de rækker, hvor orders.customer_id er det samme. Kun de kombinationer, hvor der faktisk er ordrer, kommer med i resultatet.

I vores data sker der blandt andet:

  • Anna (customer_id 1) matcher to ordrer: 100 og 101
  • Bo (2) matcher ordrer 102 og 103
  • Carla (3) matcher ordre 104
  • Dennis (4) matcher ingenting

Så resultatet har 5 rækker (en per ordre), og Dennis er væk. Ikke slettet, bare ikke valgt.

Den mest typiske fejl jeg ser hos begyndere, er at man glemmer ON-delen eller skriver den forkert. Så får man alt for mange rækker, fordi databasen laver et krydsprodukt. Hvis du får langt flere rækker ud, end der er ordrer, er det næsten altid fordi din JOIN-betingelse er forkert.

LEFT JOIN, hvornår den hjælper dig og hvornår den driller

LEFT JOIN siger: “Tag alle rækker i venstre tabel, også selvom der ingen match er i højre tabel”.

Med vores kunder og ordrer betyder det: alle kunder, og ordrer hvis de findes. De kunder uden ordrer får NULL i order-felterne.

Prøv:

SELECT
  customers.customer_id,
  customers.name,
  orders.order_id,
  orders.order_date
FROM customers
LEFT JOIN orders
  ON customers.customer_id = orders.customer_id
ORDER BY customers.customer_id, orders.order_id;

Nu er Dennis med. Hans kolonner fra orders er NULL, men han optræder som én række. Det er super nyttigt når du vil se “kunder uden ordrer”, eller bare have en komplet liste uden at miste nogen.

Her er så det, der snyder rigtig mange: hvis du bagefter smider en betingelse i WHERE på den højre tabel, kommer du til at fjerne alle de NULL-rækker, og så har du i praksis lavet en INNER JOIN igen.

Eksempel på “klassisk fælde”:

SELECT
  customers.customer_id,
  customers.name,
  orders.order_id
FROM customers
LEFT JOIN orders
  ON customers.customer_id = orders.customer_id
WHERE orders.order_date >= '2024-03-05';

Nu forsvinder Dennis og alle andre kunder uden ordrer, for orders.order_date >= ... er altid falsk, når order_date er NULL. Mange tror, de laver en “LEFT JOIN med filter”, men de har reelt bedt databasen smide alle dem uden ordrer ud.

Hvis du vil filtrere på højre tabel uden at miste de kunder uden ordrer, skal filteret ind i JOIN-betingelsen i stedet.

SELECT
  customers.customer_id,
  customers.name,
  orders.order_id,
  orders.order_date
FROM customers
LEFT JOIN orders
  ON customers.customer_id = orders.customer_id
 AND orders.order_date >= '2024-03-05';

Nu får du:

  • Alle kunder
  • Kun ordrer fra 5. marts og frem
  • NULL i order-kolonnerne når der ikke er match eller når ordren er ældre end datoen

Det her spil mellem JOIN og WHERE vender jeg lige tilbage til, for det er et af de steder, hvor selv øvede laver små huller i båden.

JOIN og WHERE, rækkefølgen og fælderne

En god huskeregel: databasen laver JOIN først (koncepetuelt), og bagefter lægger WHERE et filter ovenpå resultatet.

I praksis optimerer databasen internt, men det er sådan du skal tænke over det, når du skriver SQL.

Med INNER JOIN er det sjældent et problem. Hvis du filtrerer på felter fra højre tabel i WHERE, er det det samme som at sætte det i JOIN-betingelsen. Du får bare færre rækker. Ingen NULL-udfordringer.

Med LEFT JOIN er det som sagt anderledes. Så snart du i WHERE siger noget om højre tabel, risikerer du at smide alle dem uden match ud, og så er idéen med LEFT JOIN væk.

Et par mønstre jeg selv går efter:

  • Filtre på venstre tabel kan fint ligge i WHERE
  • Filtre på højre tabel, hvor du stadig vil beholde NULL-rækker, skal med i ON
  • Filtre der eksplicit handler om at skille dig af med NULL-rækker, hører hjemme i WHERE

Du kan læse mere om den tekniske del i blandt andet dokumentationen hos PostgreSQL, men hvis du er i starten, er det vigtigste at træne det på rigtige eksempler.

7 SQL JOIN øvelser med facit

Nu til det sjove. Jeg plejer at lære JOIN bedst ved at lave små opgaver, gå galt nogle gange, og så finjustere.

Alle øvelser tager udgangspunkt i de tre tabeller ovenfor. Prøv selv at løse dem, før du kigger på løsningen. Det giver mest mening sådan.

Øvelse 1 – vis alle ordrer med kundenavn

Opgave: Lav en forespørgsel der viser order_id, order_date og kundens name for alle ordrer.

Løsning:

SELECT
  orders.order_id,
  orders.order_date,
  customers.name
FROM orders
INNER JOIN customers
  ON orders.customer_id = customers.customer_id
ORDER BY orders.order_id;

Hvorfor det virker: Vi starter i orders, for det er dem vi vil vise. For hver ordre finder vi kunden via customer_id. INNER JOIN er perfekt her, for der er ingen idé i at vise ordrer uden kendt kunde.

Øvelse 2 – alle kunder og deres ordrer (også dem uden)

Opgave: Vis alle kunder og deres order_id. Kunder uden ordrer skal stadig med, med NULL i order_id.

Løsning:

SELECT
  customers.customer_id,
  customers.name,
  orders.order_id
FROM customers
LEFT JOIN orders
  ON customers.customer_id = orders.customer_id
ORDER BY customers.customer_id, orders.order_id;

Hvorfor det virker: Vi vil have alle kunder – så de skal til venstre i en LEFT JOIN. Ordrer er valgfrie. Dennis kommer med, men hans order_id er NULL.

Øvelse 3 – kunder med mindst én ordre

Opgave: Lav en liste over kunder, der har mindst én ordre. Hver kunde må kun stå én gang.

Løsning 1 (INNER JOIN + DISTINCT):

SELECT DISTINCT
  customers.customer_id,
  customers.name
FROM customers
INNER JOIN orders
  ON customers.customer_id = orders.customer_id
ORDER BY customers.customer_id;

Løsning 2 (LEFT JOIN + WHERE):

SELECT DISTINCT
  customers.customer_id,
  customers.name
FROM customers
LEFT JOIN orders
  ON customers.customer_id = orders.customer_id
WHERE orders.order_id IS NOT NULL
ORDER BY customers.customer_id;

Hvorfor det virker: Med INNER JOIN kommer kunder uden ordrer aldrig med, så DISTINCT er nok. Med LEFT JOIN får du også kunder uden ordrer, men du kan skille dig af med dem ved at kræve at orders.order_id ikke er NULL.

Øvelse 4 – alle ordrer med produktnavn

Opgave: Vis alle ordrer med kundens navn og produktets navn. Én række per ordre.

Løsning:

SELECT
  o.order_id,
  o.order_date,
  c.name   AS customer_name,
  p.name   AS product_name,
  o.quantity
FROM orders o
INNER JOIN customers c
  ON o.customer_id = c.customer_id
INNER JOIN products p
  ON o.product_id = p.product_id
ORDER BY o.order_id;

Hvorfor det virker: Vi JOIN’er i kæde. Først orders til customers, så det kombinerede resultat til products. Sådan løser du “sql join between three tables” i praksis: step for step, altid via matchende ID’er.

Øvelse 5 – ordrer lagt efter en dato, med kundeby

Opgave: Find alle ordrer efter 5. marts 2024 med kundens navn og by.

Løsning:

SELECT
  o.order_id,
  o.order_date,
  c.name,
  c.city
FROM orders o
INNER JOIN customers c
  ON o.customer_id = c.customer_id
WHERE o.order_date > '2024-03-05'
ORDER BY o.order_date;

Hvorfor det virker: Filtre på hovedtabellen (orders) kan roligt bo i WHERE. Du siger: JOIN alle ordrer med kunder, og bagefter sorterer du alle ordrer før en given dato fra.

Øvelse 6 – alle kunder, men kun deres nye ordrer

Opgave: Vis alle kunder, og hvis de har ordrer efter 5. marts 2024, så vis kun de ordrer. Kunder uden ordrer skal stadig med.

Forkert løsning (klassisk fejl):

SELECT
  c.customer_id,
  c.name,
  o.order_id,
  o.order_date
FROM customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id
WHERE o.order_date > '2024-03-05';

Her forsvinder alle kunder uden ordrer, for deres order_date er NULL, og det klarer WHERE ikke.

Korrekt løsning:

SELECT
  c.customer_id,
  c.name,
  o.order_id,
  o.order_date
FROM customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id
 AND o.order_date > '2024-03-05'
ORDER BY c.customer_id, o.order_id;

Hvorfor det virker: Filteret på dato ligger i JOIN’en, så Dennis stadig får en række med NULL i ordrer. Det her er en af de vigtigste forskelle på INNER JOIN vs LEFT JOIN i hverdagen.

Øvelse 7 – forbrug per kunde (aggregation + JOIN)

Opgave: Beregn hvor meget hver kunde har brugt i alt (price * quantity), og vis kun kunder der faktisk har ordrer.

Løsning:

SELECT
  c.customer_id,
  c.name,
  SUM(p.price * o.quantity) AS total_spent
FROM customers c
INNER JOIN orders o
  ON c.customer_id = o.customer_id
INNER JOIN products p
  ON o.product_id = p.product_id
GROUP BY c.customer_id, c.name
ORDER BY total_spent DESC;

Hvorfor det virker: Vi joiner først alle tre tabeller sammen, så hver række “ved” hvilken kunde, hvilket produkt og hvilken pris. Bagefter grupperer vi efter kunde og lægger totalen sammen. Aggregation + JOIN er præcis sådan mange rapporter bygges i rigtige systemer.

Hvis du får en fejl om at en kolonne ikke er i GROUP BY eller en aggregator, er det typisk fordi du har valgt en kolonne direkte (som city) uden at tilføje den til GROUP BY. De fleste databaser kræver at alle felter enten er med i GROUP BY eller er pakket ind i noget som SUM, COUNT, MIN osv.

Fejlfinding: duplikater, manglende rækker og forkerte nøgler

Når folk skriver “JOIN er svært”, mener de tit “jeg får mærkelige resultater”. Jeg har været der. Mange gange.

Der er tre klassiske fejl, jeg selv støder på, når jeg har rodet længe nok i kode til at blive lidt blind.

Du får for mange rækker (duplikater)

Hvis din SELECT pludselig returnerer 500 rækker, men du ved, at der kun er 100 ordrer, er det næsten altid fordi JOIN-betingelsen ikke er præcis nok.

Et tænkt eksempel:

SELECT *
FROM orders o
JOIN products p
  ON o.product_id <= p.product_id;

Her matcher hver ordre potentielt flere produkter, fordi betingelsen ikke er lighed, men mindre end eller lig. Det giver et krydsprodukt-lignende resultat, og tallene blæser op.

Løsning: kig altid på din ON-sætning. Skal det være =? Skal der være flere kolonner med i betingelsen? Er det egentlig den rigtige kolonne, du joiner på?

Du mister rækker du troede ville være der

Mangler du pludselig en kunde eller en ordre, så kig på:

  • Bruger du INNER JOIN der, hvor du egentlig havde tænkt LEFT JOIN?
  • Har du sat et filter i WHERE, der filtrerer NULL-rækker væk?
  • Sidder din filterlogik på højre tabel, men skulle egentlig sidde i ON?

En fin teknik er at starte med en simpel SELECT på hovedtabellen alene. Tæl rækkerne. Tilføj så JOIN. Tæl igen. Tilføj så WHERE. Tæl igen. På den måde ser du præcis hvor noget forsvinder.

Du joiner på den forkerte nøgle

Det her virker banalt, men jeg har selv lavet den mange gange, især når felterne hedder næsten det samme.

Hvis du har både customer_id og order_id, er det nemt at skrive:

ON customers.customer_id = orders.order_id

i stedet for det rigtige:

ON customers.customer_id = orders.customer_id

Resultatet kan godt “ligne noget”, men være helt forkert. Et lille tip: når noget føles mystisk, så SELECT kun nøglerne og se på dem.

SELECT
  c.customer_id AS c_id,
  o.order_id    AS o_id,
  o.customer_id AS o_customer_id
FROM customers c
JOIN orders o
  ON c.customer_id = o.customer_id
ORDER BY c.customer_id;

Når du kan se tallene ved siden af hinanden, opdager du hurtigt fejl i dine JOINs.

Vejen videre fra de første JOIN øvelser

Hvis du er kommet hertil og JOIN ikke længere føles som en sort boks, så er du godt på vej. De næste naturlige skridt er ting som:

  • OUTER JOIN-varianter som RIGHT og FULL (hvis din database understøtter dem)
  • Selv-join, hvor en tabel joiner med sig selv, typisk til hierarkier
  • Subqueries kombineret med JOIN til mere avancerede rapporter

Coding Class’ backend- og dataindhold prøver vi at bygge videre på de her grundting med API’er, filtrering og datamodeller, så JOIN ikke bare bliver en teoretisk øvelse, men noget du faktisk bruger i små projekter.

Min anbefaling er dog stadig den lavpraktiske: gem det lille datasæt fra den her artikel, find på 5 egne spørgsmål du vil kunne besvare (“hvem køber flest skærme?”, “hvad køber folk i Aarhus?”) og skriv SQL’en til det. Gentag et par aftener. Det svarer lidt til at ælte en surdej. Første gang er det rodet, men efter nogle runder begynder hænderne at vide, hvad de laver, længe før hovedet tænker over det.

Og hvis du på et tidspunkt sidder en sen aften med en JOIN der giver 10.000 rækker for meget, så er du i godt selskab. Jeg har stadig en gammel SQL-fil liggende, der kunne varme et mindre parcelhus op, bare fordi jeg glemte en ON-betingelse.

To enkle mønstre: 1) Aggregation: brug en subquery med MAX(order_date) for hvert customer_id og join tilbage på orders, fx WHERE o.order_date = (SELECT MAX(order_date) FROM orders WHERE customer_id = c.customer_id). 2) Window-funktion: ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) og vælg row_number = 1 - det er ofte nemmere når du også vil håndtere ties eller hente flere kolonner fra den seneste række.
Sørg for indeks på kolonnerne du joiner på (fx orders.customer_id, orders.product_id); primærnøgler giver typisk allerede indeks. Brug EXPLAIN for at se query-planen, vælg kun de kolonner du behøver i SELECT, og overvej covering indexes eller composite indexes hvis du ofte filtrerer på flere kolonner.
Tilføj en mellem-tabel order_items med mindst order_id, product_id og quantity. Så ser join-kæden typisk sådan ud: customers -> orders -> order_items -> products, fx SELECT c.name, o.order_id, p.name FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id.
Brug LEFT JOIN for at identificere manglende matches og COALESCE til at vise en standardværdi i stedet for NULL. Ret data i et staging-skript eller ved hjælp af constraints og foreign keys, og find orphan-rows med en anti-join (LEFT JOIN ... WHERE right_side IS NULL) for at kunne rense eller reparere kilden.

Jonas Kirkeby har skrevet kode siden han som teenager forsøgte at lave en helt simpel hjemmeside til sin fars lille vvs-firma – og endte med at sidde oppe hele natten for at få en knap til at skifte farve. Siden da har han lært sig det meste ved at prøve sig frem, kopiere andres eksempler, ødelægge dem og langsomt forstå, hvorfor tingene virker, som de gør.

Til daglig arbejder han slet ikke med IT, men bruger aftener og morgener på små projekter: en lille side til en forening, et simpelt værktøj til at holde styr på familiens madplan eller et Python-script, der rydder op i rodede filer. Det er den slags konkrete hverdags-behov, der har formet hans måde at tænke kodning på – hvad kan jeg bygge nu, som faktisk hjælper mig eller nogen, jeg kender?

På Coding Class deler Jonas de guides, han selv ville ønske, han havde haft: korte, konkrete forløb, hvor du kan se noget på skærmen efter få minutters læsning. Han viser hele vejen fra idé til færdig løsning, inklusive de typiske fejl og små snubletråde på vejen, så du ikke kun får den pæne, polerede version.

Hans mål er, at du som begynder eller let øvet hurtigt får følelsen af: “Det her kan jeg faktisk selv finde ud af” – uanset om du vil bygge din første lille hjemmeside, forstå JavaScript-funktioner eller bruge Python til at automatisere en kedelig opgave.

Send kommentar

You May Have Missed