◀ Course contents Part 2 · Module 2-05

Dictionaries

Looking things up by name instead of by position

A list answers "what is third?". A dictionary answers "what is the price of the whisk?" — and it answers it just as fast whether there are ten entries or ten million. Almost every piece of structured data you will meet at work, from a JSON API response to a config file to a row of a database, arrives in this shape.

Ready?

1

A Dictionary Maps Keys to Values

Curly braces, and pairs written key: value. You look things up by key rather than by position, so the order you wrote them in is rarely something you care about.

item = {
    "sku": "SKU-77",
    "name": "whisk",
    "price": 990,
}

item["name"]     # "whisk"
item["price"]    # 990
len(item)        # 3

Keys are usually strings and must be something unchangeable — a string, a number, or a tuple from the next module. A list cannot be a key, for a reason that will make sense the moment you have met both.

Values can be anything at all, including other dictionaries and lists. That is what lets a dictionary describe a whole record rather than a single fact.

A list of records is the usual shape

Not one dictionary — a list of them, one per row. A CSV read into Python, a JSON array from an API and a database query result all come out looking like [{...}, {...}, {...}], and that shape is worth recognising now.

Quick check

Why look something up by key rather than by searching a list for it?

2

[] Raises, .get() Does Not

Asking for a key that is not there with square brackets raises a KeyError. Often that is exactly what you want — a missing price is a bug, and a loud failure beats a quiet zero.

When a missing key is expected, .get() hands back None instead of raising, or whatever default you name:

item["colour"]           # KeyError
item.get("colour")       # None
item.get("colour", "—")  # "—"

Choose deliberately. .get() everywhere turns a missing field into a None that travels three functions before failing somewhere unrelated; [] everywhere turns an optional field into a crash. The question is whether absence is normal here.

Changing a dictionary needs no special method — assign to a key and it is created or replaced:

item["price"] = 1090      # replaces
item["colour"] = "green"  # creates
del item["colour"]        # removes, KeyError if absent
item.pop("colour", None)  # removes and hands back, or the default

And in checks keys, not values. "price" in item is True; 990 in item is False, even though 990 is sitting right there as a value.

Quick check

A config may or may not set timeout, and 30 is a sensible default. What reads best?

3

Looping, and the Counting Pattern

Three ways to walk a dictionary, and the third is the one you want most of the time:

for key in counts:            # the keys
for value in counts.values(): # the values
for key, value in counts.items():   # both, which is usually the point
    print(key, value)

Looping over the dictionary itself gives keys, not pairs — a small thing that trips everyone up once.

The pattern worth memorising is counting. In the log module you kept three separate variables for three levels, which works until a fourth level appears and does not exist in your code:

counts = {}
for level in levels:
    counts[level] = counts.get(level, 0) + 1

One line, and it handles a level it has never seen before: .get(level, 0) is 0 the first time and the running total afterwards. Without the default, the first occurrence of anything raises KeyError.

The same shape totals rather than counts — totals[key] = totals.get(key, 0) + amount — and that pair covers a surprising proportion of all reporting code.

Quick check

Why counts.get(level, 0) + 1 rather than counts[level] + 1?

4

The Shapes Real Data Arrives In

Merging. Configuration is nearly always defaults with overrides on top, and update() is exactly that — it writes every pair from one dictionary into another, replacing what is already there:

settings = DEFAULTS.copy()
settings.update(user_config)

The .copy() matters for the reason it mattered with lists: dictionaries are mutable too, and updating DEFAULTS in place would quietly change the defaults for everything else in the program.

Nesting. A value can be another dictionary, so real records go two or three levels deep. You read them one step at a time:

order = {
    "id": "T-1003",
    "customer": {"name": "Kenji", "city": "Osaka"},
    "items": ["tea", "whisk"],
}

order["customer"]["city"]   # "Osaka"
order["items"][0]           # "tea"
len(order["items"])         # 2

This is what a JSON response is. Nothing new is happening — each step is an ordinary lookup, and the answer to one is the thing the next asks.

Deep lookups fail in the middle

order["customer"]["city"] raises if customer is missing, and raises differently if city is. Chained .get() calls help — order.get("customer", {}).get("city") — at the cost of turning a structural problem into a None.

Guard at the boundary where the data arrives, so the code below it can trust the shape.

Quick check

Why DEFAULTS.copy() before update()?

0 of 9 completed

Real Python runs right here in your browser — nothing to install, nothing sent to a server. The interpreter downloads once the first time you press Run, then stays cached.

01

To do

Curly braces, pairs written key: value. You look things up by key rather than by counting positions, which is why a record is a dictionary and not a list.

item = {"sku": "SKU-77", "name": "whisk", "price": 990}
item["name"] # "whisk"

Your task: build item with those three fields, then print the name, the price, and how many fields the record has:

whisk
990
3
your_code.py
Python
Hint

The price is a number, so it needs no quotes. len(item) counts the pairs, not the characters.

Output

      
    02

    To do

    item["colour"] on a record with no colour raises a KeyError. Sometimes that is right — a missing price is a bug and should stop the program.

    When absence is expected, .get() hands back None, or a default you name:

    item.get("colour")        # None
    item.get("colour", "—") # "—"

    Your task: run it and read the KeyError, then replace that lookup so a missing colour reads as unspecified. Leave the two lookups that should succeed as they are.

    whisk
    990
    unspecified
    your_code.py
    Python
    Hint

    item.get("colour", "unspecified") — the second argument is what comes back when the key is not there.

    Output
    
          
      03

      To do

      No special method for adding. Assign to a key and it is created if it is new, replaced if it is not.

      item["price"] = 1090       # replaces
      item["colour"] = "green" # creates
      del item["sku"] # removes
      old = item.pop("colour") # removes and hands it back

      Your task: put the price up to 1090, add in_stock as True, take the sku out with del, and pop the colour into removed. Then print the record and what you popped:

      {'name': 'whisk', 'price': 1090, 'in_stock': True}
      green
      your_code.py
      Python
      Hint

      Assignment for the first two, del for the sku, and removed = item.pop("colour") for the last. pop is the only one that hands something back.

      Output
      
            
        04

        To do

        "price" in item is True. 990 in item is False — even though 990 is sitting right there as a value.

        This catches people because in on a list checks the items. On a dictionary the items are the keys, as far as in is concerned. To ask about a value, ask item.values().

        Your task: print four answers, in this order: is there a price key, is there a colour key, is 990 a key, is 990 among the values:

        True
        False
        False
        True
        your_code.py
        Python
        Hint

        The first three are plain `in item`. The last one is `990 in item.values()`.

        Output
        
              
          05

          To do

          Looping over a dictionary gives you the keys, not the pairs. When you want both — which is most of the time — .items() hands them over two at a time:

          for key, value in stock.items():
              print(key, value)

          Wrapping it in sorted() walks the keys in order, which is what you want whenever a person is going to read the output.

          Your task: print one row per item, keys in alphabetical order, with the name left-aligned in ten characters and the count right-aligned in three:

          cloth       5
          matcha 1
          tea 3
          whisk 12
          your_code.py
          Python
          Hint

          for name, count in sorted(stock.items()): then print(f"{name:<10}{count:>3}") inside.

          Output
          
                
            06

            To do

            In the log module you kept a separate variable per level. That works until a level you did not plan for turns up — and then it is not counted at all, because there is no variable for it.

            A dictionary handles a key it has never seen, in one line:

            counts[level] = counts.get(level, 0) + 1

            .get(level, 0) is 0 the first time and the running total after that. Written as counts[level] + 1 it raises KeyError on the first occurrence of everything.

            Your task: tally the levels — including DEBUG, which nobody planned for — then print the dictionary and the number of distinct levels:

            {'INFO': 3, 'ERROR': 2, 'WARN': 1, 'DEBUG': 1}
            4
            your_code.py
            Python
            Hint

            for level in levels: then counts[level] = counts.get(level, 0) + 1. The default is what stops the first sighting of DEBUG from raising.

            Output
            
                  
              07

              To do

              Change the + 1 to + amount and the counting pattern becomes a totalling one. Between them these two cover a surprising share of all reporting code.

              totals[key] = totals.get(key, 0) + amount

              .values() then gives you everything you need to summarise the result — it works with sum(), max() and the rest exactly as a list would.

              Your task: each entry is a region and an amount. Total by region, then print the totals, the grand total, and the largest single region:

              {'eu': 1750, 'us': 900, 'jp': 1200}
              3850
              1750
              your_code.py
              Python
              Hint

              for entry in sales: region, amount = entry.split(":") — then totals[region] = totals.get(region, 0) + int(amount). The amount arrives as text.

              Output
              
                    
                08

                To do

                Almost every configuration is this: a set of defaults, and the handful of things one particular deployment does differently. update() writes every pair from one dictionary into another, replacing what is already there.

                settings = DEFAULTS.copy()
                settings.update(user_config)

                The .copy() matters for the same reason it did with lists. update() changes the dictionary in place, so without it you have overwritten the defaults for the rest of the program.

                Your task: merge the two, leaving DEFAULTS exactly as it was, and print the result and then the untouched defaults:

                {'port': 9000, 'debug': True, 'timeout': 30, 'region': 'eu'}
                {'port': 8080, 'debug': False, 'timeout': 30}
                your_code.py
                Python
                Hint

                Two steps: settings = DEFAULTS.copy(), then settings.update(user_config). update() returns None, so do not assign its result.

                Output
                
                      
                  09

                  To do

                  A value can be another dictionary, or a list. That is what a JSON response is, and reading one is just an ordinary lookup whose answer is the thing the next lookup asks.

                  order["customer"]["city"]
                  order["items"][0]

                  Your task: pull four things out of the order — the customer's name, their city, the first item, and how many items there are — and print them:

                  Kenji
                  Osaka
                  tea
                  2
                  your_code.py
                  Python
                  Hint

                  One step at a time: order["customer"] is a dictionary, so ["name"] on the end of that gets the name. order["items"] is a list, so [0] and len() work on it.

                  Output
                  
                        

                    The support inbox

                    To do

                    Every morning the support tool exports the previous day's tickets and somebody summarises them. Read the export once and produce the summary.

                    Each real line is ticket id,priority,minutes spent. Blank lines and lines starting with # are not tickets.

                    Build these:

                    • counts — how many tickets at each priority
                    • minutes — total minutes spent at each priority
                    • busiest — the priority with the most tickets
                    • slowest_id and slowest_minutes — the single ticket that took longest

                    Do not assume which priorities exist. The export gains a new one the first time someone adds a level in the admin panel, and a summary that quietly drops it is worse than one that crashes.

                    Then print exactly six lines — one per priority in alphabetical order, with the name left-aligned in eight, the count right-aligned in two, and the minutes right-aligned in six followed by m:

                    Tickets: 6
                    high 3 150m
                    low 2 15m
                    medium 1 30m
                    Busiest: high
                    Slowest: T-1003 (90m)
                    your_code.py
                    Python
                    Hint

                    Loop over export.splitlines(), strip, and continue past blanks and # lines. Split the rest on the comma into three pieces; the minutes need int(). Tally with counts[priority] = counts.get(priority, 0) + 1 and minutes[priority] = minutes.get(priority, 0) + spent. Track the slowest ticket with a comparison as you go. For busiest, loop over sorted(counts) afterwards and keep the largest. The rows are f"{name:<8}{count:>2}{mins:>6}m".

                    Output
                    
                          

                      Notification