CodeKitchen prep
0%
Contest in--:--:--
Python · lesson 03/13

🧹Data wrangling patterns

⏳ ~20 min

Most "data engineering" contest problems are one of these patterns in disguise. Learn to recognise them quickly. Each snippet is self-contained and ready to adapt.

P1 · Group & aggregate (several metrics at once)

from collections import defaultdict
stats = defaultdict(lambda: {"n": 0, "sum": 0.0, "min": float("inf"), "max": float("-inf"), "users": set()})
for r in rows:
    s = stats[r["country"]]
    s["n"] += 1; s["sum"] += r["amount"]
    s["min"] = min(s["min"], r["amount"]); s["max"] = max(s["max"], r["amount"])
    s["users"].add(r["user"])                      # COUNT(DISTINCT user)
for k in sorted(stats):
    s = stats[k]
    print(k, s["n"], f'{s["sum"] / s["n"]:.2f}', len(s["users"]))

P2 · Deduplicate / keep latest version (CDC, upserts)

latest = {}
for r in records:                                  # one pass, O(#keys) memory
    k = r["id"]
    if k not in latest or r["updated_at"] >= latest[k]["updated_at"]:   # >= : later line wins ties
        latest[k] = r
# deletes in CDC streams: {"op": "d"} then drop the key
result = [r for r in latest.values() if r.get("op") != "d"]

Exact duplicates vs. key duplicates: set(map(tuple, rows)) removes identical rows, but dedup "by key" needs a rule (latest, first, max). Read the statement for which one wins on ties.

P3 · Flatten nested JSON (and explode arrays)

def flatten(d, prefix="", sep="."):
    out = {}
    for k, v in d.items():
        key = f"{prefix}{sep}{k}" if prefix else k
        if isinstance(v, dict) and v:
            out.update(flatten(v, key, sep))
        else:
            out[key] = v
    return out

# "explode": one output row per array element (like SQL UNNEST / pandas explode)
order = {"id": 7, "items": [{"sku": "A", "qty": 2}, {"sku": "B", "qty": 1}]}
rows = [{"order_id": order["id"], **item} for item in order.get("items", [])]

P4 · Hash join in Python (inner / left)

users_by_id = {u["id"]: u for u in users}         # build side = smaller table
for o in orders:                                   # probe side = bigger / streamed table
    u = users_by_id.get(o["user_id"])              # left join: u may be None
    print(o["id"], u["name"] if u else "UNKNOWN")

# one-to-many build side: index to a list
from collections import defaultdict
items_by_order = defaultdict(list)
for it in items: items_by_order[it["order_id"]].append(it)

P5 · Sessionization (inactivity gap)

events.sort(key=lambda e: (e["user"], e["ts"]))
sessions, cur = [], None
for e in events:
    if cur and cur["user"] == e["user"] and e["ts"] - cur["end"] <= GAP:
        cur["end"] = e["ts"]; cur["n"] += 1
    else:
        if cur: sessions.append(cur)
        cur = {"user": e["user"], "start": e["ts"], "end": e["ts"], "n": 1}
if cur: sessions.append(cur)                        # don't forget the last one!

P6 · Merge overlapping intervals

Use this for subscriptions, maintenance windows, "total time online" and booking conflicts.

def merge(intervals):                 # [(start, end), ...]
    out = []
    for s, e in sorted(intervals):
        if out and s <= out[-1][1]:     # use < if touching intervals should NOT merge
            out[-1][1] = max(out[-1][1], e)
        else:
            out.append([s, e])
    return out

# max concurrent sessions: sweep line
points = sorted([(s, 1) for s, e in intervals] + [(e, -1) for s, e in intervals])   # (e,-1) sorts before (e,1): end before start
cur = best = 0
for _, d in points:
    cur += d; best = max(best, cur)

P7 · Streaks / gaps & islands

from datetime import date, timedelta
def longest_streak(days):              # days: iterable of date
    days = sorted(set(days))
    best = run = 0; prev = None
    for d in days:
        run = run + 1 if prev and d - prev == timedelta(days=1) else 1
        best = max(best, run); prev = d
    return best

P8 · Cleaning & normalisation

import re, unicodedata
from datetime import datetime

def clean_str(s):
    if s is None: return None
    s = unicodedata.normalize("NFKC", s).strip()
    s = re.sub(r"\s+", " ", s)
    return s or None                       # "" becomes NULL

def to_float(x):
    try: return float(str(x).replace(",", ""))
    except (TypeError, ValueError): return None

FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d %H:%M", "%Y-%m-%dT%H:%M:%S")
def parse_dt(s):
    for f in FORMATS:
        try: return datetime.strptime(s.strip(), f)
        except ValueError: pass
    return None                            # quarantine instead of crashing

Persian or Arabic digits might appear in Iranian-made inputs: "۱۲۳". You can convert with s.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")). Also note that int("۱۲۳") already works in Python 3!

P9 · Validate & quarantine (data quality)

good, bad = [], []
for i, r in enumerate(rows):
    errs = []
    if not r.get("id"): errs.append("missing id")
    if r.get("amount") is not None and r["amount"] < 0: errs.append("negative amount")
    if r.get("email") and not re.fullmatch(r"[^@\s]+@[^@\s]+\.\w+", r["email"]): errs.append("bad email")
    (bad if errs else good).append((i, r, errs))

The usual data-quality dimensions are completeness (nulls), validity (type, range, regex), uniqueness (PK dupes), consistency (FK exists, totals match), timeliness (freshness) and accuracy.

P10 · Pivot / unpivot in pure Python

pivot = defaultdict(lambda: defaultdict(int))
cols = set()
for r in rows:
    pivot[r["user"]][r["status"]] += 1; cols.add(r["status"])
cols = sorted(cols)
print("user", *cols)
for u in sorted(pivot):
    print(u, *(pivot[u][c] for c in cols))    # missing → 0 (defaultdict)

P11 · Rolling window (moving average over the last N)

from collections import deque
win, s = deque(), 0
for x in values:
    win.append(x); s += x
    if len(win) > N: s -= win.popleft()
    print(f"{s / len(win):.2f}")      # O(1) per step

P12 · Incremental load with a watermark + upsert

def incremental_load(target: dict, source_rows, watermark):
    new_wm = watermark
    for r in source_rows:
        if r["updated_at"] <= watermark:     # already processed
            continue
        target[r["id"]] = r                  # UPSERT (idempotent: rerun → same state)
        new_wm = max(new_wm, r["updated_at"])
    return target, new_wm                    # persist new_wm only AFTER a successful write

P13 · Too big for memory → stream / chunk / partition

# 1) stream line by line (generators)       2) pandas: read_csv(..., chunksize=100_000)
# 3) external sort: sort chunks → write runs → heapq.merge(*runs)
# 4) hash-partition by key into K files → each partition fits in memory → process separately
import heapq
def external_sort(lines, chunk=100_000):
    runs, buf = [], []
    for line in lines:
        buf.append(line)
        if len(buf) == chunk:
            runs.append(sorted(buf)); buf = []    # (in real life: write each run to a temp file)
    runs.append(sorted(buf))
    return heapq.merge(*runs)

P14 · Top-K per group (without pandas)

import heapq
from collections import defaultdict
by_cat = defaultdict(list)
for p in products:
    by_cat[p["category"]].append(p)
for cat in sorted(by_cat):
    top = heapq.nsmallest(3, by_cat[cat], key=lambda p: (-p["sales"], p["name"]))   # sales DESC, name ASC
    print(cat, *(p["name"] for p in top))
# keep ties like RANK(): find the K-th value, then keep everything >= it
#   cutoff = sorted({p["sales"] for p in grp}, reverse=True)[:K][-1]

nsmallest with a negated key is the trick for "descending, then ascending": it gives you a deterministic tie-break, which nlargest(key=…) on its own does not.

P15 · Fill the gaps (date spine / zero rows)

from collections import Counter
from datetime import date, timedelta
def daterange(start: date, end: date):          # inclusive
    for i in range((end - start).days + 1):
        yield start + timedelta(days=i)

counts = Counter(r["day"] for r in rows)        # only days that HAVE data
for d in daterange(date(2026, 5, 1), date(2026, 5, 7)):
    print(d.isoformat(), counts.get(d.isoformat(), 0))   # days with no data → 0

"Report every day / hour / month, including empty ones" is a frequent hidden-test trap. A Counter only knows about keys it has seen. Build the full calendar and LEFT-JOIN onto it, in SQL or in Python.

P16 · As-of lookup (value valid at a point in time)

import bisect
rates.sort()                                     # [(ts, rate), ...]
times = [t for t, _ in rates]
def rate_at(ts):
    i = bisect.bisect_right(times, ts) - 1       # last rate with time <= ts
    return rates[i][1] if i >= 0 else None
# SCD2 lookup is the same idea: the row with valid_from <= ts < valid_to

P17 · Parse defensively, quarantine the rest (JSON Lines)

import json, sys
good, bad = [], []
for lineno, line in enumerate(sys.stdin, 1):
    if not line.strip():
        continue
    try:
        rec = json.loads(line)
        if not isinstance(rec, dict):
            raise ValueError("not an object")
        amount = rec["amount"]                    # KeyError if missing
        if isinstance(amount, bool) or not isinstance(amount, (int, float)):
            raise ValueError("amount is not a number")
        good.append(rec)
    except (ValueError, KeyError, TypeError) as e:
        bad.append((lineno, str(e)))             # keep the reason: that's what a DLQ is for

Statements often say "ignore invalid lines" and then count on you to define invalid correctly. Make a list from the statement: broken JSON, wrong top-level type, missing field, wrong field type, empty line. Then test each one.

Quiz You need the latest record per key from a 50 GB append-only log that doesn't fit in RAM. Best approach?
Quiz "A new session starts after more than 30 minutes of inactivity." Two events are exactly 30:00 apart. They are…
🏁 --:--