← All tutorials
PythonData Analysis

Python Dictionaries in Practice: Building Lookup Tables with Craft Beer Data

Dictionaries aren't just key-value pairs — they're how you join, count, and group data without a library. This tutorial uses a real craft beer dataset (2,410 beers, 558 breweries) to build a lookup table that joins two CSV files, then counts, groups, and summarizes with nothing but built-in Python.

Most real datasets don’t arrive as one tidy table — they arrive as several related files that only make sense together. A beers.csv file that names a brewery only by an ID number. A breweries.csv file that has the actual names. Somewhere, something has to connect the two. In Pandas or SQL, that something is called a join. In plain Python, it’s a dictionary.

This is where a lot of people who “know dictionaries” get stuck: they can create one, read a key, loop over .items() — but they’ve never used a dictionary as a bridge between two datasets. For the mechanics of .get(), .setdefault(), and safe reads and writes from the ground up, our Python dictionaries guide covers that in depth on a small bookshop example, and our data structures overview compares dictionaries with lists, tuples, and sets side by side. This post assumes you’ve got the basics and puts them to work on a real, messy, 2,410-row dataset of craft beers.

The Mental Model: A Dictionary Is a Lookup Table

A lookup table is any dictionary you build for one purpose: given a key you already have, hand back a value you don’t. That’s it — no loops, no scanning.

  1. Build it once. Turn a whole second dataset into {key: value} pairs — usually with a dictionary comprehension over its rows.
  2. Look up by key, not by searching. lookup[key] finds the match directly; you never scan the second dataset row by row.
  3. Use .get() with a fallback when a key might be missing, so an unmatched record doesn’t crash your script.
  4. Attach the result to the record you started with, and you’ve just performed a join — the same operation a database or Pandas merge() does, done with one dictionary.
Diagram showing a beer record with brewery_id '408' looked up in a brewery_lookup dictionary built from breweries.csv, where key '408' matches the value '10 Barrel Brewing Co.', and that brewery name is attached onto the beer record as a new field, producing a joined record.

Keep this model in mind through the rest of the post: almost every pattern below — counting, grouping, nested summaries — is a variation on “build a dictionary once, then use it.”

The Dataset: 2,410 Craft Beers, 558 Breweries

Data: the Craft Beers Dataset compiled by Jean-Nicholas Hould (MIT license), via github.com/nickhould/craft-beers-dataset, vendored at /datasets/blog/python-dictionaries-craft-beer/. It’s two small CSV files — beers.csv (2,410 rows) and breweries.csv (558 rows) — fetched straight from this site so you can reproduce every number below without a separate download:

import csv
import urllib.request

BASE = "https://datatweets.com/datasets/blog/python-dictionaries-craft-beer"

with urllib.request.urlopen(f"{BASE}/beers.csv") as resp:
    beers = list(csv.DictReader(line.decode("utf-8") for line in resp))
with urllib.request.urlopen(f"{BASE}/breweries.csv") as resp:
    breweries = list(csv.DictReader(line.decode("utf-8") for line in resp))

print(f"{len(beers)} beers, {len(breweries)} breweries")
print(beers[0])
2410 beers, 558 breweries
{'': '0', 'abv': '0.05', 'ibu': '', 'id': '1436', 'name': 'Pub Beer', 'style': 'American Pale Lager', 'brewery_id': '408', 'ounces': '12.0'}

csv.DictReader hands you each row already as a dictionary, keyed by the header row — no manual parsing needed. Two things to notice right away: the blank '' key is the file’s own leftover row-index column (harmless — we’ll just ignore it), and ibu came back as an empty string for this beer, not a number. Every value from csv.DictReader is a string, so the numeric columns need cleanup before we can do math on them:

for b in beers:
    b["abv"] = float(b["abv"]) if b["abv"] else None
    b["ibu"] = int(float(b["ibu"])) if b["ibu"] else None
    b["ounces"] = float(b["ounces"]) if b["ounces"] else None

print(beers[0])
{'': '0', 'abv': 0.05, 'ibu': None, 'id': '1436', 'name': 'Pub Beer', 'style': 'American Pale Lager', 'brewery_id': '408', 'ounces': 12.0}

Missing IBU becomes None instead of '' — an honest gap we’ll have to handle later, not paper over. abv is stored as a fraction (0.05, not 5), a detail worth remembering before you print one.

Reading and Updating a Real Record

Everything from the dictionaries basics still applies — it’s just operating on a row that came from a file instead of one you typed in:

first = beers[0]
print(first["name"])
print(first["abv"])
print(first.get("ibu"))
print(first.get("ibu", "not measured"))
Pub Beer
0.05
None
None

.get("ibu") and .get("ibu", "not measured") both return None here — not because the key is missing, but because the value itself is None. .get() protects you from a missing key, not a missing value. Suppose a lab test later confirms this beer’s bitterness at 40 IBU — updating and adding a key use the same [] assignment:

first["ibu"] = 40
first["notes"] = "lab-confirmed IBU"
print(first)

removed = first.pop("notes", None)
print(removed)
print(first)
{'': '0', 'abv': 0.05, 'ibu': 40, 'id': '1436', 'name': 'Pub Beer', 'style': 'American Pale Lager', 'brewery_id': '408', 'ounces': 12.0, 'notes': 'lab-confirmed IBU'}
lab-confirmed IBU
{'': '0', 'abv': 0.05, 'ibu': 40, 'id': '1436', 'name': 'Pub Beer', 'style': 'American Pale Lager', 'brewery_id': '408', 'ounces': 12.0}

ibu existed already, so the assignment overwrote it; notes didn’t, so it was added. .pop() removed notes and handed back its value — the record is back to its original shape.

Building a Lookup Table to Join Two Files

Here’s the pattern from the mental model, for real. beers.csv only knows a brewery by its numeric brewery_id. The actual name lives in breweries.csv, keyed by id. A dictionary comprehension turns the second file into a lookup table in one line:

brewery_lookup = {b["id"]: b["name"].strip() for b in breweries}
print(len(brewery_lookup))
print(brewery_lookup["408"])
558
10 Barrel Brewing Company

Now every beer can find its brewery in one step, with .get() guarding against an ID that doesn’t match anything:

for b in beers:
    b["brewery"] = brewery_lookup.get(b["brewery_id"], "Unknown Brewery")

print(beers[0]["name"], "-", beers[0]["brewery"])
unmatched = sum(1 for b in beers if b["brewery"] == "Unknown Brewery")
print("beers with no matching brewery:", unmatched)
Pub Beer - 10 Barrel Brewing Company
beers with no matching brewery: 0

Zero unmatched beers — this dataset’s IDs line up cleanly, which won’t always be true of real data, which is exactly why the .get() fallback is there instead of a bare [] lookup. The csv module documents everything else it can do: dialects, custom delimiters, writing files.

Counting Styles

With every beer joined to a brewery name, the classic dictionary job is counting. How many beers of each style are in the dataset?

style_counts = {}
for b in beers:
    style = b["style"]
    if not style:
        continue
    style_counts[style] = style_counts.get(style, 0) + 1

top5 = sorted(style_counts.items(), key=lambda kv: kv[1], reverse=True)[:5]
for style, count in top5:
    print(f"{style:35s} {count}")
print("total distinct styles:", len(style_counts))
American IPA                        424
American Pale Ale (APA)             245
American Amber / Red Ale            133
American Blonde Ale                 108
American Double / Imperial IPA      105
total distinct styles: 99

The .get(style, 0) pattern initializes an unseen style at zero before adding one — no if style in style_counts check required. collections.Counter is the built-in shortcut for exactly this:

from collections import Counter

style_counter = Counter(b["style"] for b in beers if b["style"])
print(style_counter.most_common(5))
[('American IPA', 424), ('American Pale Ale (APA)', 245), ('American Amber / Red Ale', 133), ('American Blonde Ale', 108), ('American Double / Imperial IPA', 105)]

American IPA alone accounts for 424 of 2,410 beers — this dataset skews heavily toward hoppy American styles.

Grouping by Brewery

Dictionaries of lists group related rows together. Which breweries brew the most beers in this dataset?

from collections import defaultdict

by_brewery = defaultdict(list)
for b in beers:
    by_brewery[b["brewery"]].append(b["name"])

top_breweries = sorted(by_brewery.items(), key=lambda kv: len(kv[1]), reverse=True)[:5]
for brewery, names in top_breweries:
    print(f"{brewery:30s} {len(names)} beers")
Brewery Vivant                 62 beers
Oskar Blues Brewery            46 beers
Sun King Brewing Company       38 beers
Cigar City Brewing Company     25 beers
Sixpoint Craft Ales            24 beers

defaultdict(list) creates an empty list the first time a brewery name is seen, so .append() always has something to append to — the same convenience .get() gave you for counting.

Nested Dictionaries: Per-Style Averages (Handling Missing Data Honestly)

A dictionary’s values can be anything, including other dictionaries. Let’s build a per-style summary of average ABV and IBU — and this is where the missing IBU values from earlier become unavoidable:

style_stats = {}
for b in beers:
    style = b["style"]
    if not style:
        continue
    if style not in style_stats:
        style_stats[style] = {"count": 0, "abv_total": 0.0, "abv_n": 0, "ibu_total": 0, "ibu_n": 0}
    stats = style_stats[style]
    stats["count"] += 1
    if b["abv"] is not None:
        stats["abv_total"] += b["abv"]
        stats["abv_n"] += 1
    if b["ibu"] is not None:
        stats["ibu_total"] += b["ibu"]
        stats["ibu_n"] += 1

for style, stats in style_stats.items():
    stats["avg_abv"] = round(stats["abv_total"] / stats["abv_n"] * 100, 1) if stats["abv_n"] else None
    stats["avg_ibu"] = round(stats["ibu_total"] / stats["ibu_n"], 1) if stats["ibu_n"] else None

top5_by_count = sorted(style_stats.items(), key=lambda kv: kv[1]["count"], reverse=True)[:5]
print(f"{'Style':35s} {'Count':>5s} {'AvgABV%':>8s} {'AvgIBU':>7s} {'IBUKnown':>9s}")
for style, s in top5_by_count:
    print(f"{style:35s} {s['count']:5d} {s['avg_abv']:8.1f} {s['avg_ibu']:7.1f} {s['ibu_n']:9d}/{s['count']}")
Style                               Count  AvgABV%  AvgIBU  IBUKnown
American IPA                          424      6.5    67.6       301/424
American Pale Ale (APA)               245      5.5    44.9       153/245
American Amber / Red Ale              133      5.7    36.3        77/133
American Blonde Ale                   108      5.0    21.0        61/108
American Double / Imperial IPA        105      8.7    93.3        75/105

Each key in style_stats maps to its own dictionary — that’s nesting. Chain brackets to reach two levels deep: style_stats["American IPA"]["avg_ibu"]. Notice the IBUKnown column: only 301 of 424 American IPAs have a recorded bitterness value. Dividing by ibu_n — the count of beers that actually had an IBU — rather than the full style count is what keeps the average honest instead of silently dragging it toward zero.

Dictionary Comprehensions and a Silent Collision

Just as list comprehensions build lists, dictionary comprehensions build dictionaries in one line. Let’s create a name-to-ABV lookup:

abv_lookup = {b["name"]: round(b["abv"] * 100, 1) for b in beers if b["abv"] is not None}
print(len(abv_lookup), "of", len(beers))
2245 of 2410

Only 2,245 entries came out, not 2,410. Beer names in this dataset aren’t unique — 82 names appear more than once — and a dictionary comprehension keeps only the last value it sees for a repeated key, silently overwriting the earlier ones:

matches = [b for b in beers if b["name"] == "Vanilla Porter"]
print("Vanilla Porter appears", len(matches), "times, abv values:", [round(m["abv"] * 100, 1) for m in matches])
print("abv_lookup kept:", abv_lookup["Vanilla Porter"])
Vanilla Porter appears 2 times, abv values: [7.0, 4.7]
abv_lookup kept: 4.7

Two different breweries each made a “Vanilla Porter,” at 7.0% and 4.7% ABV — the comprehension kept whichever it processed last, silently discarding the first. That’s not a bug; it’s rule one of dictionaries (one value per key) showing up somewhere easy to miss. Need every match instead? Group into a list, the same defaultdict(list) pattern from grouping by brewery.

Filtering in the same expression finds strong beers in one line:

strong = {name: abv for name, abv in abv_lookup.items() if abv > 9.0}
print(len(strong), "beers above 9% ABV")
print(sorted(strong.items(), key=lambda kv: kv[1], reverse=True)[:3])
82 beers above 9% ABV
[('Lee Hill Series Vol. 5 - Belgian Style Quadrupel Ale', 12.8), ('London Balling', 12.5), ('Csar', 12.0)]

Finding Extremes

max() with a key function finds the strongest beer directly, no sorting the whole dataset required:

has_abv = [b for b in beers if b["abv"] is not None]
strongest = max(has_abv, key=lambda b: b["abv"])
print(f"{strongest['name']} ({strongest['brewery']}) - {strongest['abv']*100:.1f}% ABV, {strongest['style']}")
Lee Hill Series Vol. 5 - Belgian Style Quadrupel Ale (Upslope Brewing Company) - 12.8% ABV, Quadrupel (Quad)

Most bitter has to filter out the missing IBUs first — max() would happily compare None against a number and crash:

has_ibu = [b for b in beers if b["ibu"] is not None]
most_bitter = max(has_ibu, key=lambda b: b["ibu"])
print(f"{most_bitter['name']} ({most_bitter['style']}) - {most_bitter['ibu']} IBU")
Bitter Bitch Imperial IPA (American Double / Imperial IPA) - 138 IBU

Three Gotchas Worth Knowing

A naive average crashes the moment one value is None. Averaging every American IPA’s IBU without filtering first raises a TypeError, because Python won’t add a number to None:

ipa_ibus = [b["ibu"] for b in beers if b["style"] == "American IPA"]
none_count = sum(1 for v in ipa_ibus if v is None)
print(f"{none_count} of {len(ipa_ibus)} American IPA records have no IBU")

try:
    bad_avg = sum(ipa_ibus) / len(ipa_ibus)
except TypeError as e:
    print(f"TypeError: {e}")

clean_ibus = [v for v in ipa_ibus if v is not None]
print(f"{sum(clean_ibus) / len(clean_ibus):.1f}")
123 of 424 American IPA records have no IBU
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
67.6

Filtering None out before summing is the fix — and 67.6 matches the nested-dictionary table earlier, computed a different way, a good sign it’s right.

Duplicate keys in a comprehension don’t warn you — they just overwrite. The Vanilla Porter collision above is the general case: any dictionary comprehension built from data with repeated keys keeps only the last match. If your source data might have duplicates and you need all of them, build a list per key instead of a single value.

ABV is stored as a fraction, not a percentage — multiply before you print it. Printing beer["abv"] directly gives you 0.072, not 7.2:

sample = beers[500]
print(sample["name"], sample["abv"])
print(f"{sample['abv'] * 100:.1f}%")
Native Amber 0.063
6.3%

Forget the * 100 in a report and every number in it is off by a factor of a hundred — a mistake that’s easy to make once and never notice, because the code still runs without error.

Wrapping Up

A dictionary is a lookup table waiting to happen: build one from a dataset’s rows, then use it to join, count, or group without ever scanning the whole thing twice.

  • {row["id"]: row["value"] for row in data} → build a lookup table in one line
  • .get(key, default) → look something up safely when a key (or match) might not exist
  • .get(key, 0) in a loop, or Counter → count occurrences
  • defaultdict(list) → group related items without an existence check
  • nested dicts ({key: {inner_key: value}}) → per-group summaries with more than one number each
  • comprehensions → build or filter a dictionary in one line, but watch for silently overwritten duplicate keys

If you want the dictionary fundamentals this post assumes — .setdefault(), safe reads, the mutable-default-argument trap — in more depth, the Python Dictionaries and Advanced Dictionaries and Frequency Tables lessons in our free Python for Data Analytics course cover exactly that, with more hands-on exercises than fit in a single post.

More tutorials