🦆 Bolagshack

Guide · Python

DuckDB i Python

Sju steg från rå CSV till rena tabeller i Python, så att du kan blanda SQL med vanlig kod. Tabellerna skriver du själv.

Den här sidan visar hur man gör varje moment i uppgiften från Python, med små exempel, så att du kan blanda SQL med vanlig kod.

Det här är fusklappar. Försök själv först, med dokumentationen och DESCRIBE/SUMMARIZE som stöd. Kika här bara när du kört fast.

Börja med en fil hack.py med ett skripthuvud som uv läser. Inget venv, ingen pip:

python
# /// script
# requires-python = ">=3.11"
# dependencies = ["duckdb"]
# ///
import duckdb
sh
uv run hack.py

con.sql("...") returnerar en relation. .show() skriver ut den, .fetchall() ger en lista med tupler, .df() en pandas-DataFrame (lägg till "pandas" i dependencies), .pl() polars.

1. Titta innan du gissar

DuckDB läser en CSV som om den vore en tabell. Ingen import behövs:

python
con = duckdb.connect()
con.sql("SELECT * FROM 'data/job_skills.csv' LIMIT 5").show()

Kolumnerna och typerna DuckDB gissade sig till:

python
con.sql("DESCRIBE SELECT * FROM 'data/job_skills.csv'").show()

SUMMARIZE ger min, max, antal unika och andel null per kolumn. Det är det snabbaste sättet att hitta det som är stökigt:

python
con.sql("SUMMARIZE SELECT * FROM 'data/people_skills.csv'").show(max_width=120)

Titta särskilt på kolumner som är VARCHAR men egentligen är något annat: en lista, ett JSON-dokument, ett datum.

2. Spara arbetet i en fil

Öppna en databas på disk så finns tabellerna kvar mellan körningar, och kan öppnas i terminalen (duckdb hack.db) och webgränssnittet (duckdb -ui hack.db). Bara en process åt gången får skriva, så stäng CLI:t innan du kör skriptet.

python
con = duckdb.connect("hack.db")
con.sql("CREATE OR REPLACE TABLE jobs   AS SELECT * FROM 'data/job_skills.csv'")
con.sql("CREATE OR REPLACE TABLE people AS SELECT * FROM 'data/people_skills.csv'")
con.sql("SHOW TABLES").show()

CREATE OR REPLACE TABLE är praktiskt när du itererar: kör skriptet om och om igen utan att först droppa något.

3. En sträng med kommatecken är en lista

skills ser ut så här: "python, sql, azure". Tre funktioner räcker:

python
con.sql("SELECT string_split('python, sql, azure', ',') AS lista").show()
con.sql("SELECT unnest(string_split('python, sql, azure', ',')) AS rad").show()
con.sql("SELECT trim(unnest(string_split('python, sql, azure', ','))) AS ren").show()

Kör dem en i taget och titta på vad som händer med resultatet. unnest gör en lista till rader, och de andra kolumnerna i SELECT följer med på varje rad. Så här ser det ut på riktig data, för ett enda uppdrag:

python
con.sql("""
    SELECT job_id, trim(unnest(string_split(skills, ','))) AS skill
    FROM jobs
    WHERE job_id = (SELECT min(job_id) FROM jobs)
""").show()

Din uppgift: gör en tabell job_skill med kolumnerna job_id, skill, och en person_skill på samma sätt. SELECT DISTINCT tar bort dubbletter. Kontrollera med count(*) att antalet rader är rimligt:

python
print(con.sql("SELECT count(*) FROM job_skill").fetchone()[0])

4. JSON i en sträng

skills_detailed i people är en JSON-lista med nivå per skill. DuckDB kan parsa den, och ->> plockar ut ett fält som text:

python
con.sql("""
    SELECT s ->> 'skill_name' AS skill, (s ->> 'level')::INT AS level
    FROM unnest(from_json('[{"skill_name":"sql","level":4}]', '["json"]')) AS t(s)
""").show()

Byt ut den hårdkodade strängen mot kolumnen och lägg till FROM people, före unnest-delen (samma mönster som i steg 3), så har du nivå per person och skill. Dokumentation: https://duckdb.org/docs/stable/data/json/overview

Alternativet är att parsa i Python med json.loads och loopa, men låt DuckDB göra det: det är kortare och blir en tabell direkt.

5. Städa namn

Räkna först. Sorterat efter antal syns dubbletterna direkt:

python
con.sql("""
    SELECT skill, count(*) AS n
    FROM job_skill
    GROUP BY skill
    ORDER BY n DESC
""").show()

Sök sedan efter varianter av samma sak. Här är det praktiskt att ta ut resultatet i Python och titta med vanlig kod:

python
rows = con.sql("SELECT DISTINCT skill FROM job_skill").fetchall()
for (skill,) in rows:
    if "azure" in skill:
        print(skill)

Det finns inget rätt svar på om "azure devops" är "azure". Men det finns ett bra sätt att uttrycka beslutet: en tabell skill som mappar råa namn till ett kanoniskt, som du sedan joinar mot. I Python kan mappningen vara en dict som du INSERT:ar med parametrar:

python
canon = {"microsoft azure": "azure", "azure": "azure"}
con.sql("CREATE OR REPLACE TABLE skill (raw VARCHAR, name VARCHAR)")
con.executemany("INSERT INTO skill VALUES (?, ?)", list(canon.items()))

Verktyg att fylla den med: lower(), trim(), replace(), CASE WHEN ... ILIKE, och för det luddiga jaro_winkler_similarity(a, b) som ger ett tal 0–1 för hur lika två strängar är. Allt som inte finns i skill-tabellen kan få behålla sitt råa namn (coalesce).

python
con.sql("SELECT jaro_winkler_similarity('microsoft azure', 'azure')").show()

Funktioner: https://duckdb.org/docs/stable/sql/functions/text

6. Joina och räkna

När job_skill, person_skill och skill finns är analysfrågorna vanlig SQL: JOIN, GROUP BY, count(*). Två saker att ha koll på:

python
con.sql("""
    SELECT date_trunc('month', date::TIMESTAMP) AS month, count(*) AS n
    FROM jobs
    GROUP BY month
    ORDER BY month
""").show()

Parametrar i stället för strängkonkatenering, när Python-värden ska in i frågan:

python
con.execute("SELECT count(*) FROM job_skill WHERE skill = ?", ["python"]).fetchone()

7. Spara ut resultatet

python
con.sql("COPY person_skill TO 'output/person_skill.parquet'")
con.sql("COPY (SELECT ...) TO 'output/top_skills.csv'")

Parquet-filen kan läsas tillbaka med SELECT * FROM 'output/person_skill.parquet', från vilken maskin som helst. Vill du ha resultatet som Python-objekt i stället: .fetchall(), .df() eller .pl() på relationen.