Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Python

Flattening, Formatting, and Exporting Nested JSON APIs to pandas DataFrames: Handling json_normalize, Nested Lists, and Missing Keys

Real-world APIs return deeply nested JSON that breaks naive DataFrame loading. Learn how to use json_normalize, explode, and defensive sanitization to flatten any nested structure—including lists of sub-objects and missing keys—into a clean, analysis-ready DataFrame. Includes a complete, production-ready pipeline function.

⚡ Practitioner21 min readSep 22, 2026Updated Sep 23, 2026
Flattening, Formatting, and Exporting Nested JSON APIs to pandas DataFrames: Handling json_normalize, Nested Lists, and Missing Keys
On this page
  • Prerequisites
  • Understanding the Shape of the Problem
  • Step 1: Flattening Nested Dicts with json_normalize
  • Controlling Depth and Separator
  • Step 2: Handling Nested Lists with explode and json_normalize Together
  • The json_normalize record_path Approach (Alternative)
  • Step 3: Safely Handling Missing and Inconsistent Keys
  • The Three Failure Modes
  • Defensive Sanitization Function
  • Handling Empty Lists After explode
  • Step 4: Cleaning, Renaming, and Typing the Flattened DataFrame
  • Step 5: Exporting to Excel and CSV
  • Exporting to CSV
  • Exporting to Excel with Formatting
  • Putting It All Together: A Production-Ready Pipeline Function
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting reset_index After explode
  • Mistake 2: Using json_normalize on a Series Directly
  • Mistake 3: Mutating the Original Records
  • Mistake 4: Assuming Column Names After Normalization
  • Mistake 5: Not Handling Deeply Nested Lists Within Lists
  • Summary & Next Steps
  • Flattening, Formatting, and Exporting Nested JSON APIs to pandas DataFrames: Handling json_normalize, Nested Lists, and Missing Keys

    Real-world APIs don't hand you a tidy spreadsheet. They hand you a deeply nested blob of JSON where your actual data lives three levels down, some records are missing fields that others have, and what should be a single row is actually a list of sub-objects that needs to be exploded before it makes any sense. If you've ever stared at a response from a REST API and thought "how do I turn this into a DataFrame I can actually analyze?"—this lesson is for you.

    We'll work through the complete pipeline from raw API response to a clean, analysis-ready DataFrame using a realistic e-commerce orders API as our anchor scenario. You'll learn not just the mechanics of json_normalize and explode, but why nested JSON breaks naive approaches and how to build defensive code that handles missing keys without crashing on production data.

    What you'll learn:

    • How json_normalize flattens nested dicts and where it falls short
    • How to handle records containing nested lists (arrays of sub-objects)
    • How to safely handle missing or inconsistent keys across records
    • How to rename, reorder, and type-cast columns after flattening
    • How to export the final result to Excel and CSV in a stakeholder-ready format

    Prerequisites

    You should be comfortable loading data into pandas and doing basic DataFrame operations. If you're newer to pandas, review Your First pandas DataFrame: Loading CSV and Excel Files and Exploring Data before continuing. You should also understand Python dictionaries and lists—the building blocks of JSON—at the level covered in Python Basics for Excel Users: Variables, Lists, Dictionaries, and Loops.


    Understanding the Shape of the Problem

    Before writing a single line of pandas code, you need to understand what kind of nesting you're dealing with. JSON nesting comes in two fundamentally different forms, and they require different solutions.

    Nested dicts look like this:

    {
      "order_id": "ORD-1001",
      "customer": {
        "name": "Priya Nair",
        "email": "priya@example.com"
      }
    }
    

    Here, customer is a dict. To access name, you'd write record["customer"]["name"]. When you flatten this, you want customer.name and customer.email as columns alongside order_id.

    Nested lists look like this:

    {
      "order_id": "ORD-1001",
      "items": [
        {"sku": "SHOE-42", "qty": 2, "price": 89.99},
        {"sku": "SOCK-M", "qty": 4, "price": 9.99}
      ]
    }
    

    Here, items is a list of dicts. This is fundamentally different—it means one order maps to multiple rows. You can't just flatten these into columns because there's a variable number of them per record. This is the harder case, and it's where most people get stuck.

    Production APIs typically give you both at once. Let's build a realistic dataset that mirrors what you'd actually get from an e-commerce API.

    import json
    import pandas as pd
    from pandas import json_normalize
    
    # Simulated API response - what you'd get from requests.get(...).json()
    raw_orders = [
        {
            "order_id": "ORD-1001",
            "created_at": "2024-03-15T09:22:00Z",
            "status": "shipped",
            "customer": {
                "id": "CUST-441",
                "name": "Priya Nair",
                "email": "priya@example.com",
                "tier": "gold"
            },
            "shipping": {
                "method": "express",
                "cost": 12.50,
                "address": {
                    "city": "Austin",
                    "state": "TX",
                    "country": "US"
                }
            },
            "items": [
                {"sku": "SHOE-42", "name": "Trail Runner", "qty": 2, "unit_price": 89.99},
                {"sku": "SOCK-M",  "name": "Merino Socks", "qty": 4, "unit_price": 9.99}
            ],
            "discount": {"code": "SPRING20", "amount": 15.00}
        },
        {
            "order_id": "ORD-1002",
            "created_at": "2024-03-15T11:05:00Z",
            "status": "pending",
            "customer": {
                "id": "CUST-887",
                "name": "Marcus Webb",
                "email": "mwebb@example.com",
                "tier": "standard"
            },
            "shipping": {
                "method": "standard",
                "cost": 5.99,
                "address": {
                    "city": "Denver",
                    "state": "CO",
                    "country": "US"
                }
            },
            "items": [
                {"sku": "PACK-L", "name": "Daypack 30L", "qty": 1, "unit_price": 129.00}
            ]
            # Note: no "discount" key on this record
        },
        {
            "order_id": "ORD-1003",
            "created_at": "2024-03-15T14:18:00Z",
            "status": "delivered",
            "customer": {
                "id": "CUST-103",
                "name": "Yuki Tanaka",
                "email": "yuki@example.com"
                # Note: no "tier" key on this customer
            },
            "shipping": {
                "method": "standard",
                "cost": 5.99,
                "address": {
                    "city": "Portland",
                    "state": "OR",
                    "country": "US"
                }
            },
            "items": [
                {"sku": "HAT-OS",  "name": "Sun Hat",     "qty": 1, "unit_price": 34.99},
                {"sku": "SHOE-38", "name": "Trail Runner", "qty": 1, "unit_price": 89.99},
                {"sku": "GLOVE-S", "name": "Liner Gloves", "qty": 2, "unit_price": 24.99}
            ],
            "discount": {"code": "LOYAL10", "amount": 8.00}
        }
    ]
    

    Notice the deliberate inconsistencies: ORD-1002 has no discount key, and ORD-1003's customer has no tier. This is normal in real APIs—never assume every record has every field.


    Step 1: Flattening Nested Dicts with json_normalize

    json_normalize is pandas' purpose-built tool for flattening nested dicts. Let's start by applying it naively and see what we get.

    # Naive first attempt
    df_naive = json_normalize(raw_orders)
    print(df_naive.columns.tolist())
    print(df_naive.shape)
    

    Output:

    ['order_id', 'created_at', 'status', 'customer.id', 'customer.name',
     'customer.email', 'customer.tier', 'shipping.method', 'shipping.cost',
     'shipping.address.city', 'shipping.address.state', 'shipping.address.country',
     'items', 'discount.code', 'discount.amount']
    (3, 15)
    

    That's already impressive. json_normalize traversed multiple levels deep—notice shipping.address.city came out correctly even though address was nested inside shipping, which was itself nested. The dotted column names reflect the path through the JSON tree.

    But look at the items column. Let's inspect it:

    print(df_naive['items'].iloc[0])
    # [{'sku': 'SHOE-42', 'name': 'Trail Runner', 'qty': 2, 'unit_price': 89.99},
    #  {'sku': 'SOCK-M', 'name': 'Merino Socks', 'qty': 4, 'unit_price': 9.99}]
    
    print(type(df_naive['items'].iloc[0]))
    # <class 'list'>
    

    json_normalize left the nested list intact as a Python list object in a single cell. This is correct behavior—it can't flatten a list into columns because the number of items varies per record. We'll handle that separately.

    Also notice something important about the missing keys. ORD-1002 had no discount key, but our DataFrame still has discount.code and discount.amount columns—those values are just NaN for that row. And ORD-1003's missing customer.tier also becomes NaN. That's exactly what you want.

    Key insight

    json_normalize is tolerant of missing keys by default. When a key is absent from one record but present in others, it fills with NaN. This behavior means your DataFrame schema is driven by the union of all keys across all records—which is usually what you want.

    Controlling Depth and Separator

    By default, json_normalize uses a . as the separator between levels. You can change this—some teams prefer underscores to avoid confusion with method calls on column names:

    df = json_normalize(raw_orders, sep='_')
    print(df.columns.tolist())
    # ['order_id', 'created_at', 'status', 'customer_id', 'customer_name',
    #  'customer_email', 'customer_tier', 'shipping_method', 'shipping_cost',
    #  'shipping_address_city', 'shipping_address_state', 'shipping_address_country',
    #  'items', 'discount_code', 'discount_amount']
    

    You can also control nesting depth with the max_level parameter. If you only want to go one level deep (useful when the inner structures are complex and you want to handle them separately):

    df_shallow = json_normalize(raw_orders, sep='_', max_level=1)
    # Now 'shipping_method' and 'shipping_cost' are flat, 
    # but 'shipping_address' stays as a dict object
    

    For our use case, letting it go all the way is the right call since we know the structure.


    Step 2: Handling Nested Lists with explode and json_normalize Together

    This is where the real complexity lives. Our items column contains lists of dicts—each order can have one, two, or three items. We need to expand this so each item becomes its own row, with the order-level fields repeated.

    The pandas approach has two steps:

    1. explode() to turn the list into one-row-per-item (each cell becomes a dict)
    2. Another json_normalize() call to flatten those dicts into columns
    # Start from our flat DataFrame (with items still as a list)
    df = json_normalize(raw_orders, sep='_')
    
    # Step 1: explode the items column
    df_exploded = df.explode('items').reset_index(drop=True)
    
    print(df_exploded[['order_id', 'items']].head(6))
    

    Output:

      order_id                                              items
    0  ORD-1001  {'sku': 'SHOE-42', 'name': 'Trail Runner', ...}
    1  ORD-1001  {'sku': 'SOCK-M', 'name': 'Merino Socks', ...}
    2  ORD-1002  {'sku': 'PACK-L', 'name': 'Daypack 30L', ...}
    3  ORD-1003  {'sku': 'HAT-OS', 'name': 'Sun Hat', ...}
    4  ORD-1003  {'sku': 'SHOE-38', 'name': 'Trail Runner', ...}
    5  ORD-1003  {'sku': 'GLOVE-S', 'name': 'Liner Gloves', ...}
    

    Now we have 6 rows (2 items for ORD-1001, 1 for ORD-1002, 3 for ORD-1003) with each items cell containing a single dict. The order-level columns like customer_name and shipping_method are correctly repeated.

    Now we normalize those item dicts into proper columns:

    # Step 2: normalize the items column
    items_normalized = json_normalize(df_exploded['items'])
    items_normalized.columns = ['item_' + col for col in items_normalized.columns]
    
    print(items_normalized.head())
    

    Output:

       item_sku       item_name  item_qty  item_unit_price
    0   SHOE-42    Trail Runner         2            89.99
    1    SOCK-M    Merino Socks         4             9.99
    2    PACK-L     Daypack 30L         1           129.00
    3    HAT-OS        Sun Hat          1            34.99
    4   SHOE-38    Trail Runner         1            89.99
    

    Now we join these columns back to the exploded DataFrame:

    # Drop the original 'items' column, join the normalized item columns
    df_final = df_exploded.drop(columns=['items']).reset_index(drop=True)
    df_final = pd.concat([df_final, items_normalized], axis=1)
    
    print(df_final.shape)
    # (6, 18)
    
    print(df_final[['order_id', 'customer_name', 'item_sku', 'item_qty', 'item_unit_price']].to_string())
    

    Output:

      order_id customer_name item_sku  item_qty  item_unit_price
    0  ORD-1001    Priya Nair  SHOE-42         2            89.99
    1  ORD-1001    Priya Nair   SOCK-M         4             9.99
    2  ORD-1002   Marcus Webb   PACK-L         1           129.00
    3  ORD-1003   Yuki Tanaka  HAT-OS          1            34.99
    4  ORD-1003   Yuki Tanaka  SHOE-38         1            89.99
    5  ORD-1003   Yuki Tanaka GLOVE-S          2            24.99
    

    Tip

    Always reset_index(drop=True) after explode(). Without it, rows that came from the same original record share the same index value, which causes subtle bugs when you use pd.concat() with axis=1—the join will align on index and produce unexpected NaN values.

    The json_normalize record_path Approach (Alternative)

    json_normalize also has a built-in record_path parameter designed specifically for this use case. It's worth knowing because it can be more concise for simpler structures:

    df_via_recordpath = json_normalize(
        raw_orders,
        record_path='items',
        meta=[
            'order_id',
            'created_at',
            'status',
            ['customer', 'name'],
            ['customer', 'email'],
            ['shipping', 'method'],
            ['shipping', 'cost']
        ],
        meta_prefix='order_',
        sep='_',
        errors='ignore'  # Critical for missing keys!
    )
    

    The record_path tells json_normalize which list to explode. The meta parameter specifies which top-level or nested fields to bring along as repeated metadata on each row. The errors='ignore' parameter is essential—without it, missing keys will raise a KeyError.

    Warning

    The record_path approach works elegantly for one level of nesting, but it breaks down when you have multiple lists at the same level (e.g., items and returns both being lists in the same record). In those cases, the explode-then-normalize pattern gives you more control.


    Step 3: Safely Handling Missing and Inconsistent Keys

    We touched on this briefly, but it deserves its own section because missing keys are the single most common source of crashes when processing real APIs.

    The Three Failure Modes

    1. The missing top-level key — A field that's entirely optional in the API spec. Our discount field is an example. json_normalize handles this gracefully with NaN.

    2. The missing nested key — A field that exists in the parent dict but has a key missing inside it. Our customer.tier is an example. Also handled with NaN.

    3. The None/null value that breaks further processing — Sometimes an API returns "shipping": null (Python None) rather than omitting the key entirely. This is nastier because json_normalize can't traverse None as if it were a dict.

    Let's add a fourth order that demonstrates the third failure mode:

    raw_orders_v2 = raw_orders + [
        {
            "order_id": "ORD-1004",
            "created_at": "2024-03-15T16:00:00Z",
            "status": "cancelled",
            "customer": {
                "id": "CUST-220",
                "name": "Felix Ramos",
                "email": "felix@example.com",
                "tier": "standard"
            },
            "shipping": None,  # API returned null for cancelled order
            "items": [],       # Empty list
            "discount": None
        }
    ]
    
    # This will crash:
    # df_bad = json_normalize(raw_orders_v2, sep='_')
    

    When shipping is None, json_normalize will try to iterate over it and fail. You need to sanitize before normalizing.

    Defensive Sanitization Function

    The right approach is a preprocessing function that normalizes your records before handing them to json_normalize:

    def sanitize_record(record: dict, defaults: dict = None) -> dict:
        """
        Replace None values for dict-type fields with empty dicts,
        and None values for list-type fields with empty lists.
        Optionally fill in default values for missing keys.
        """
        if defaults is None:
            defaults = {}
        
        # Apply defaults for missing keys
        record = {**defaults, **record}
        
        # Replace None dicts with empty dicts
        dict_fields = ['shipping', 'customer', 'discount']
        list_fields = ['items']
        
        for field in dict_fields:
            if record.get(field) is None:
                record[field] = {}
        
        for field in list_fields:
            if record.get(field) is None:
                record[field] = []
        
        return record
    
    # Apply sanitization
    clean_orders = [sanitize_record(r) for r in raw_orders_v2]
    
    # Now normalization is safe
    df = json_normalize(clean_orders, sep='_')
    print(df[['order_id', 'shipping_method', 'shipping_cost']].to_string())
    

    Output:

      order_id shipping_method  shipping_cost
    0  ORD-1001         express          12.50
    1  ORD-1002        standard           5.99
    2  ORD-1003        standard           5.99
    3  ORD-1004             NaN            NaN
    

    ORD-1004's shipping_method and shipping_cost are now NaN rather than causing a crash.

    Note

    The {**defaults, **record} dict merge syntax ensures that keys in record always win over defaults. This is the Pythonic way to apply defaults without overwriting actual data. If you're on Python 3.8 or earlier, this syntax works fine—it's been available since Python 3.5.

    Handling Empty Lists After explode

    The empty items list on ORD-1004 also creates a subtle issue after explode():

    df_exploded = df.explode('items').reset_index(drop=True)
    
    # Check what happened to ORD-1004
    print(df_exploded[df_exploded['order_id'] == 'ORD-1004'][['order_id', 'items']])
    

    Output:

      order_id items
    6  ORD-1004   NaN
    

    When you explode an empty list, pandas converts it to NaN. That's actually fine for our use case—it means ORD-1004 appears as one row with NaN item fields, which preserves the order in our dataset without creating phantom rows.

    But when you run json_normalize(df_exploded['items']), it will fail on that NaN. The fix:

    # Only normalize non-null items
    mask = df_exploded['items'].notna()
    
    items_normalized = json_normalize(df_exploded.loc[mask, 'items'].tolist())
    items_normalized.index = df_exploded.loc[mask].index
    items_normalized.columns = ['item_' + col for col in items_normalized.columns]
    
    df_final = df_exploded.drop(columns=['items'])
    df_final = df_final.join(items_normalized)  # Join on index
    
    print(df_final[['order_id', 'item_sku', 'item_qty']].to_string())
    

    Output:

      order_id item_sku  item_qty
    0  ORD-1001  SHOE-42       2.0
    1  ORD-1001   SOCK-M       4.0
    2  ORD-1002   PACK-L       1.0
    3  ORD-1003  HAT-OS        1.0
    4  ORD-1003  SHOE-38       1.0
    5  ORD-1003  GLOVE-S       2.0
    6  ORD-1004      NaN       NaN
    

    ORD-1004 appears with NaN item fields—the order is preserved, no crash.


    Step 4: Cleaning, Renaming, and Typing the Flattened DataFrame

    After flattening, you typically have column names that reflect the JSON path but aren't ideal for analysis or reporting. Let's do a proper post-normalization cleanup.

    # Full pipeline producing df_final with 7 rows (including ORD-1004)
    # (assuming the code from Steps 1-3 above has run)
    
    # Step 1: Rename columns to something human-readable
    column_map = {
        'order_id': 'order_id',
        'created_at': 'created_at',
        'status': 'order_status',
        'customer_id': 'customer_id',
        'customer_name': 'customer_name',
        'customer_email': 'customer_email',
        'customer_tier': 'customer_tier',
        'shipping_method': 'shipping_method',
        'shipping_cost': 'shipping_cost',
        'shipping_address_city': 'ship_city',
        'shipping_address_state': 'ship_state',
        'shipping_address_country': 'ship_country',
        'discount_code': 'discount_code',
        'discount_amount': 'discount_amount',
        'item_sku': 'item_sku',
        'item_name': 'item_name',
        'item_qty': 'item_qty',
        'item_unit_price': 'item_unit_price',
    }
    
    df_clean = df_final.rename(columns=column_map)
    
    # Step 2: Fix data types
    df_clean['created_at'] = pd.to_datetime(df_clean['created_at'], utc=True)
    df_clean['shipping_cost'] = pd.to_numeric(df_clean['shipping_cost'], errors='coerce')
    df_clean['item_qty'] = pd.to_numeric(df_clean['item_qty'], errors='coerce').astype('Int64')
    df_clean['item_unit_price'] = pd.to_numeric(df_clean['item_unit_price'], errors='coerce')
    df_clean['discount_amount'] = df_clean['discount_amount'].fillna(0.0)
    df_clean['customer_tier'] = df_clean['customer_tier'].fillna('standard')  # Business default
    
    # Step 3: Add a calculated column
    df_clean['item_total'] = df_clean['item_qty'] * df_clean['item_unit_price']
    
    # Step 4: Reorder columns logically
    col_order = [
        'order_id', 'created_at', 'order_status',
        'customer_id', 'customer_name', 'customer_email', 'customer_tier',
        'ship_city', 'ship_state', 'ship_country',
        'shipping_method', 'shipping_cost',
        'discount_code', 'discount_amount',
        'item_sku', 'item_name', 'item_qty', 'item_unit_price', 'item_total'
    ]
    
    df_clean = df_clean[col_order]
    
    print(df_clean.dtypes)
    print(df_clean.head(7).to_string())
    

    Tip

    Use pd.to_numeric(series, errors='coerce') instead of direct casting when you're not 100% sure the column is clean. errors='coerce' turns unparseable values into NaN rather than raising an exception. You can then decide how to handle those NaN values downstream. This is especially important for data coming from APIs where numeric fields occasionally contain strings like "N/A".

    Notice we used pandas' nullable integer type 'Int64' (capital I) rather than the standard int64. This is important because item_qty has NaN values for ORD-1004's row, and standard int64 can't hold NaN. See Cleaning Messy Data with pandas: Missing Values, Duplicates, and Data Types for a full treatment of nullable dtypes.


    Step 5: Exporting to Excel and CSV

    Now that your DataFrame is clean and structured, exporting is the final step. For most stakeholders, Excel is the preferred delivery format. For downstream systems or archiving, CSV or JSON works better.

    Exporting to CSV

    df_clean.to_csv('orders_flat.csv', index=False, date_format='%Y-%m-%d %H:%M:%S')
    

    The date_format argument ensures your datetime column exports in a readable format rather than pandas' default ISO 8601 with microseconds.

    Exporting to Excel with Formatting

    For a more polished Excel output, use openpyxl directly via a pd.ExcelWriter context manager:

    from openpyxl.styles import PatternFill, Font, Alignment
    from openpyxl.utils import get_column_letter
    
    output_path = 'orders_report.xlsx'
    
    with pd.ExcelWriter(output_path, engine='openpyxl', datetime_format='YYYY-MM-DD HH:MM:SS') as writer:
        df_clean.to_excel(writer, sheet_name='Order Line Items', index=False)
        
        workbook = writer.book
        worksheet = writer.sheets['Order Line Items']
        
        # Style the header row
        header_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
        header_font = Font(color='FFFFFF', bold=True)
        
        for col_num, column_title in enumerate(df_clean.columns, 1):
            cell = worksheet.cell(row=1, column=col_num)
            cell.fill = header_fill
            cell.font = header_font
            cell.alignment = Alignment(horizontal='center')
        
        # Auto-fit column widths
        for col_num, column in enumerate(df_clean.columns, 1):
            col_letter = get_column_letter(col_num)
            max_length = max(
                len(str(column)),
                df_clean[column].astype(str).str.len().max()
            )
            worksheet.column_dimensions[col_letter].width = min(max_length + 2, 40)
        
        # Freeze the header row
        worksheet.freeze_panes = 'A2'
    
    print(f"Exported {len(df_clean)} rows to {output_path}")
    

    For more on building polished Excel exports, including multi-sheet workbooks and conditional formatting, see Automating Excel Reports with pandas and openpyxl: Formatted Workbooks Without Manual Work.


    Putting It All Together: A Production-Ready Pipeline Function

    In real projects, you'll call this pipeline repeatedly—whenever you pull a fresh batch from the API. Wrapping it in a function makes it reusable and testable.

    import pandas as pd
    from pandas import json_normalize
    from typing import Optional
    
    
    def flatten_orders_api(
        raw_records: list[dict],
        include_cancelled: bool = True
    ) -> pd.DataFrame:
        """
        Flatten a list of raw order records from the e-commerce API
        into a tidy, one-row-per-line-item DataFrame.
        
        Parameters
        ----------
        raw_records : list of dicts
            The raw JSON response from the orders API.
        include_cancelled : bool
            If False, drops rows where order_status == 'cancelled'.
        
        Returns
        -------
        pd.DataFrame
        """
        
        # --- 1. Sanitize records ---
        def sanitize(record):
            record = dict(record)  # shallow copy to avoid mutating original
            for field in ['shipping', 'customer', 'discount']:
                if record.get(field) is None:
                    record[field] = {}
            if record.get('items') is None:
                record['items'] = []
            # Fill missing nested keys
            record['customer'].setdefault('tier', None)
            return record
        
        clean_records = [sanitize(r) for r in raw_records]
        
        # --- 2. Normalize the top-level structure ---
        df = json_normalize(clean_records, sep='_')
        
        # --- 3. Explode and normalize items ---
        df = df.explode('items').reset_index(drop=True)
        
        mask = df['items'].notna()
        if mask.any():
            items_df = json_normalize(df.loc[mask, 'items'].tolist())
            items_df.index = df.loc[mask].index
            items_df.columns = ['item_' + c for c in items_df.columns]
            df = df.drop(columns=['items']).join(items_df)
        else:
            df = df.drop(columns=['items'])
        
        # --- 4. Rename columns ---
        rename_map = {
            'order_id': 'order_id',
            'created_at': 'created_at',
            'status': 'order_status',
            'customer_id': 'customer_id',
            'customer_name': 'customer_name',
            'customer_email': 'customer_email',
            'customer_tier': 'customer_tier',
            'shipping_method': 'shipping_method',
            'shipping_cost': 'shipping_cost',
            'shipping_address_city': 'ship_city',
            'shipping_address_state': 'ship_state',
            'shipping_address_country': 'ship_country',
            'discount_code': 'discount_code',
            'discount_amount': 'discount_amount',
            'item_sku': 'item_sku',
            'item_name': 'item_name',
            'item_qty': 'item_qty',
            'item_unit_price': 'item_unit_price',
        }
        # Only rename columns that exist (guards against schema drift)
        rename_map = {k: v for k, v in rename_map.items() if k in df.columns}
        df = df.rename(columns=rename_map)
        
        # --- 5. Type conversions ---
        df['created_at'] = pd.to_datetime(df['created_at'], utc=True, errors='coerce')
        df['shipping_cost'] = pd.to_numeric(df['shipping_cost'], errors='coerce')
        df['item_qty'] = pd.to_numeric(df['item_qty'], errors='coerce').astype('Int64')
        df['item_unit_price'] = pd.to_numeric(df['item_unit_price'], errors='coerce')
        df['discount_amount'] = pd.to_numeric(df['discount_amount'], errors='coerce').fillna(0.0)
        df['customer_tier'] = df['customer_tier'].fillna('standard')
        
        # --- 6. Calculated columns ---
        df['item_total'] = df['item_qty'] * df['item_unit_price']
        
        # --- 7. Optional filter ---
        if not include_cancelled:
            df = df[df['order_status'] != 'cancelled'].reset_index(drop=True)
        
        # --- 8. Column ordering (only include columns that exist) ---
        desired_order = [
            'order_id', 'created_at', 'order_status',
            'customer_id', 'customer_name', 'customer_email', 'customer_tier',
            'ship_city', 'ship_state', 'ship_country',
            'shipping_method', 'shipping_cost',
            'discount_code', 'discount_amount',
            'item_sku', 'item_name', 'item_qty', 'item_unit_price', 'item_total'
        ]
        final_cols = [c for c in desired_order if c in df.columns]
        return df[final_cols]
    
    
    # Usage
    df_orders = flatten_orders_api(raw_orders_v2, include_cancelled=False)
    print(df_orders.to_string())
    

    Key insight

    Building your pipeline as a function—rather than a notebook of sequential cells—makes it dramatically easier to reuse. You can call it inside a scheduled job, pass fresh API responses to it, and write unit tests against it. If you're thinking about structuring your project files properly, Structuring a Reusable Data Analysis Project: Functions, Modules, Notebooks, and Scripts covers exactly how to organize this kind of code.


    Hands-On Exercise

    Here's a dataset from a fictional support ticket API. Apply the full pipeline—sanitize, normalize, explode, clean, export.

    raw_tickets = [
        {
            "ticket_id": "TKT-5001",
            "submitted_at": "2024-03-20T08:15:00Z",
            "priority": "high",
            "assignee": {"id": "AGT-11", "name": "Dana Holt", "team": "tier2"},
            "customer": {"id": "CUST-441", "name": "Priya Nair"},
            "tags": [
                {"label": "billing", "category": "finance"},
                {"label": "refund", "category": "finance"}
            ],
            "resolution": {"status": "resolved", "time_hours": 2.5}
        },
        {
            "ticket_id": "TKT-5002",
            "submitted_at": "2024-03-20T09:30:00Z",
            "priority": "medium",
            "assignee": {"id": "AGT-07", "name": "James Yi", "team": "tier1"},
            "customer": {"id": "CUST-887", "name": "Marcus Webb"},
            "tags": [
                {"label": "shipping", "category": "logistics"}
            ]
            # Note: no "resolution" key - ticket still open
        },
        {
            "ticket_id": "TKT-5003",
            "submitted_at": "2024-03-20T10:45:00Z",
            "priority": "low",
            "assignee": None,  # Unassigned
            "customer": {"id": "CUST-103", "name": "Yuki Tanaka"},
            "tags": [],  # No tags yet
            "resolution": None  # Explicitly null
        }
    ]
    

    Your tasks:

    1. Write a sanitize_ticket() function that handles None dicts and empty lists
    2. Use json_normalize with sep='_' to flatten the top-level + nested dicts
    3. Explode the tags column and normalize the tag dicts
    4. Rename columns, fix data types (parse submitted_at as datetime), and add a is_resolved boolean column based on whether resolution_status is "resolved"
    5. Export the result to a CSV file named tickets_flat.csv

    Expected output shape: 4 rows × ~15 columns (TKT-5003 with no tags should appear as one row with NaN tag fields)


    Common Mistakes & Troubleshooting

    Mistake 1: Forgetting reset_index After explode

    # WRONG - will cause NaN-filled rows when joined
    df_exploded = df.explode('items')
    items_df = json_normalize(df_exploded['items'].dropna().tolist())
    df_final = pd.concat([df_exploded.drop(columns='items'), items_df], axis=1)
    
    # RIGHT - reset index first so concat aligns correctly
    df_exploded = df.explode('items').reset_index(drop=True)
    

    Mistake 2: Using json_normalize on a Series Directly

    # WRONG - json_normalize expects a list of dicts, not a Series of dicts
    items_df = json_normalize(df_exploded['items'])  
    
    # RIGHT - convert to list first, then handle the index separately
    items_df = json_normalize(df_exploded['items'].dropna().tolist())
    items_df.index = df_exploded[df_exploded['items'].notna()].index
    

    Actually in recent versions of pandas, json_normalize does accept a Series—but it ignores the index, which causes silent misalignment. Being explicit with .tolist() and manually setting the index is safer.

    Mistake 3: Mutating the Original Records

    # WRONG - modifies raw_orders in place
    for record in raw_orders:
        if record.get('shipping') is None:
            record['shipping'] = {}
    
    # RIGHT - work on a copy
    clean_records = [dict(r) for r in raw_orders]
    for record in clean_records:
        if record.get('shipping') is None:
            record['shipping'] = {}
    

    If raw_orders came from requests.json() or a database, mutating it in place can cause hard-to-trace bugs if the same object is used elsewhere in your code.

    Mistake 4: Assuming Column Names After Normalization

    The json_normalize output columns depend on what keys exist in the data. If you hardcode column names downstream without checking:

    # This crashes if 'discount_code' doesn't exist (e.g., no records had discounts)
    df['discount_applied'] = df['discount_code'].notna()
    
    # RIGHT - guard first
    if 'discount_code' in df.columns:
        df['discount_applied'] = df['discount_code'].notna()
    else:
        df['discount_applied'] = False
    

    This is especially important when building pipelines that process data incrementally—early batches might not contain all fields.

    Warning

    Schema drift is a real risk with live APIs. If the API team adds a new nested field or renames one, your pipeline's rename map will silently produce wrong or missing columns. Add a validation step after flattening that checks for the minimum expected set of columns and raises an informative error if any are missing. The approaches covered in Validating and Profiling a New Dataset with pandas: Row Counts, Distributions, and Outlier Checks Before You Analyze apply equally well to API data.

    Mistake 5: Not Handling Deeply Nested Lists Within Lists

    Some APIs go further—a record might have a list of orders, each containing a list of items, each containing a list of attributes. json_normalize + explode works one level at a time. For two levels of nesting, you need to chain the process:

    # Level 1: explode orders
    df = df.explode('orders').reset_index(drop=True)
    df = pd.concat([df.drop(columns='orders'), 
                    json_normalize(df['orders'].dropna().tolist())
                    .set_index(df[df['orders'].notna()].index)], axis=1)
    
    # Level 2: explode items within orders
    df = df.explode('items').reset_index(drop=True)
    # ... and so on
    

    For APIs with truly deep nesting, consider writing a recursive flattening function, or look at libraries like flatten_json that automate this.


    Summary & Next Steps

    You now have a complete toolkit for turning nested JSON API responses into clean, analysis-ready DataFrames. The key principles to remember:

    • json_normalize handles nested dicts by creating dotted column names—it's tolerant of missing keys by default
    • Nested lists require explode + a second json_normalize—don't try to flatten them into columns
    • Sanitize before you normalize—replace None dicts with {} and None lists with [] to avoid crashes
    • Set and restore the index carefully when using explode + concat or join
    • Guard against schema drift with defensive column checks in your pipeline

    With your data now in a proper tabular structure, you're ready to do the interesting analytical work. Some natural next steps from here:

    • Use Grouping and Aggregating in pandas: groupby as the PivotTable Replacement to compute revenue by customer tier, average order value by shipping method, or items sold per SKU
    • If this pipeline runs daily, wrap it in a scheduler using the patterns in Building and Automating Recurring Reports with pandas: Scheduling Scripts to Run Without You
    • If your flattened dataset gets large, review Handling Large Datasets in Python: Chunked Reading, Efficient dtypes, and When to Use Polars for performance strategies

    The pattern you learned here—sanitize, normalize, explode, normalize again, clean, export—applies to virtually any nested JSON source, whether it's a REST API, a NoSQL database dump, or a webhook payload. Master this shape and you'll be able to handle the messy, real-world data that most analysts find overwhelming.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Python for Data Analysis

    Previous

    Ranking and Window Calculations in pandas: Running Totals, Cumulative Averages, and Percent Rank with groupby and transform

    Next

    Building a Dynamic pandas Report Pipeline with Parameters, Filters, and Excel Output

    Related Insights

    PythonExpert

    Building a Customer Churn Prediction Scoring Table in pandas: Calculating Behavioral Flags, Weighted Risk Scores, and Exporting a Ranked Watch List to Excel

    25 min
    PythonPractitioner

    Building an Inventory Aging Report in pandas: Classifying Stock by Days on Hand, Flagging Slow Movers, and Exporting a Tiered Summary to Excel

    22 min
    PythonFoundation

    Calculating Descriptive Statistics in pandas: mean, median, std, and describe for Summarizing Numeric Columns Before You Analyze

    15 min

    On this page

    • Prerequisites
    • Understanding the Shape of the Problem
    • Step 1: Flattening Nested Dicts with json_normalize
    • Controlling Depth and Separator
    • Step 2: Handling Nested Lists with explode and json_normalize Together
    • The json_normalize record_path Approach (Alternative)
    • Step 3: Safely Handling Missing and Inconsistent Keys
    • The Three Failure Modes
    • Defensive Sanitization Function
    • Handling Empty Lists After explode
    • Step 4: Cleaning, Renaming, and Typing the Flattened DataFrame
    • Step 5: Exporting to Excel and CSV
    • Exporting to CSV
    • Exporting to Excel with Formatting
    • Putting It All Together: A Production-Ready Pipeline Function
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Forgetting reset_index After explode
    • Mistake 2: Using json_normalize on a Series Directly
    • Mistake 3: Mutating the Original Records
    • Mistake 4: Assuming Column Names After Normalization
    • Mistake 5: Not Handling Deeply Nested Lists Within Lists
    • Summary & Next Steps