Multi-Source Data Aggregator: Combining Two Public APIs Into One Clean Dataset

Data Science & ML Projects (Beginner)

Chapter 6 · Multi-Source Data Aggregator: Combining Two Public APIs Into One Clean Dataset

Chapter 3 fetched from one API. This chapter fetches from two — independent services, built by different teams, sharing no obvious common key — and combines them into one clean dataset using ds1-5's own merge material, applied to a real join key that has to be derived rather than one that's simply handed to you.

What We're Building

A dataset combining country information from the REST Countries API (population, capital, currency — free, no signup) with live currency exchange rates from an open exchange-rate API (also free, no signup) — producing one table showing each country's population alongside its own currency's current value against the US dollar. Neither API alone can answer "which populous countries currently have the weakest currency" — only the combination can.

Step 1: Fetching From the First API

import requests
import pandas as pd

response = requests.get(
    "https://restcountries.com/v3.1/region/europe",
    params={"fields": "name,capital,population,currencies"},
)
countries_data = response.json()
print(len(countries_data), "countries returned")

Same requests.get(url, params={...}) pattern Chapter 3 introduced — just a different service, restricted to one region to keep this example a manageable size.

Step 2: Extracting a Currency Code From Nested JSON

records = []
for c in countries_data:
    currencies = c.get("currencies", {})
    currency_code = list(currencies.keys())[0] if currencies else None
    records.append({
        "country": c["name"]["common"],
        "capital": c.get("capital", [None])[0],
        "population": c["population"],
        "currency_code": currency_code,
    })

countries_df = pd.DataFrame(records)
print(countries_df.head())

Each country's currency arrives as a nested dict keyed by its own three-letter code (e.g. {"EUR": {...}}) — currency_code here isn't a field this API hands over directly, it's derived by pulling the first key out of that nested structure. This derived value is exactly what makes the merge in Step 4 possible at all.

Step 3: Fetching From the Second, Unrelated API

rates_response = requests.get("https://open.er-api.com/v6/latest/USD")
rates_data = rates_response.json()["rates"]

rates_df = pd.DataFrame(list(rates_data.items()), columns=["currency_code", "usd_rate"])
print(rates_df.head())

This API has never heard of REST Countries and doesn't organize its data by country at all — it's a flat dict of currency codes to exchange rates. rates_data.items() turns that dict into a list of (code, rate) pairs, which becomes a two-column DataFrame — the same currency-code column countries_df already has, arrived at completely independently.

Step 4: The Merge — ds1-5's Own Material, on a Derived Key

combined = countries_df.merge(rates_df, on="currency_code", how="left")
print(combined.sort_values("population", ascending=False).head(10))
Why how="left", and what it honestly reveals
how="left" keeps every country from countries_df even if its currency code has no match in rates_df — some currency codes are obscure enough that a live exchange-rate feed simply doesn't track them. Those rows end up with NaN in usd_rate, exactly the missing-value situation ds1-4's own cleaning material covers — a real, honest consequence of combining two independently-maintained data sources rather than a mistake in this chapter's own code.

A Quick Combined Insight

top_by_population = combined.sort_values("population", ascending=False).head(10)
print(top_by_population[["country", "population", "currency_code", "usd_rate"]])

A table like this — population from one API, live currency strength from a completely different one — genuinely doesn't exist as a single downloadable file anywhere. It only exists because this chapter built it, on the spot, from two independent sources.

The Complete Aggregator

import requests, pandas as pd

countries_data = requests.get(
    "https://restcountries.com/v3.1/region/europe",
    params={"fields": "name,capital,population,currencies"},
).json()

records = []
for c in countries_data:
    currencies = c.get("currencies", {})
    records.append({
        "country": c["name"]["common"],
        "capital": c.get("capital", [None])[0],
        "population": c["population"],
        "currency_code": list(currencies.keys())[0] if currencies else None,
    })
countries_df = pd.DataFrame(records)

rates_data = requests.get("https://open.er-api.com/v6/latest/USD").json()["rates"]
rates_df = pd.DataFrame(list(rates_data.items()), columns=["currency_code", "usd_rate"])

combined = countries_df.merge(rates_df, on="currency_code", how="left")
combined.to_csv("country_currency_combined.csv", index=False)
print(f"Combined {len(combined)} countries across two independent APIs.")
Now considerate of two services, not one
Chapter 3's own API-courtesy point applies doubled here — this script makes exactly one call to each API per run, not one call per country, which is exactly why the second API was chosen because it returns every currency's rate in a single response rather than requiring a separate call per country. Combining sources doesn't have to mean multiplying request volume; sometimes the right design choice avoids the problem entirely.

Independently-shaped sources

Two APIs, two different JSON structures, no shared convention between them.

Deriving a join key

currency_code isn't handed over directly — it's pulled from nested JSON first.

how="left" and its honest cost

Keeps every row from the primary source; unmatched keys become real, meaningful NaNs.

Request-count-aware design

One call per API, not one call per row — considerate by design, not just by delay.

Extend This Project

Try these on your own:

  • Change the region to "asia", "africa", or "americas" and compare how many currency codes fail to find a match in each region.
  • Use ds1-4's own cleaning toolkit to decide, explicitly, what to do with the countries that ended up with a missing usd_rate — drop them, or leave them flagged.
  • Add a third source: a real, free country-flag or ISO-code API, merged in on a different derived key.
  • Build a scatter plot of population vs. usd_rate and look, cautiously (per Chapter 5's own selection-bias warning), for any pattern worth investigating further rather than concluding one exists.

What's Next

Chapter 7: A First Real Classifier — this course's own single, deliberately light touch of ml1, a small, approachable classification project on a well-known dataset.

Chapter 6 Quick Reference

  • Extends Chapter 3's own single-API pattern to two independent APIs, combined via ds1-5's own merge material
  • A join key can be derived (pulled out of nested JSON) rather than handed over directly by either source
  • how="left" preserves the primary source's own rows; unmatched keys become real, honest missing values (ds1-4)
  • Choosing an API that returns all needed data in one call avoids one-request-per-row volume entirely
  • The resulting combined table doesn't exist anywhere as a single downloadable file — it's only produced by the combination itself