← Back to all projects Python · API · PostgreSQL · SQL

COVID-19: API to PostgreSQL Pipeline

I pulled live country data from a public API, cleaned it in pandas, loaded it into PostgreSQL, modelled it as dimension and fact views, and answered seven questions in SQL.

ToolsPython · requests · pandas · SQLAlchemy · PostgreSQL
Sourcedisease.sh public API
Scope229 countries
Snapshot24 Sep 2026
229Countries loaded
2Cruise ships removed
0.37Correlation: population vs cases
75Countries above average fatality
The pipeline

From API to database

Collect

Called the disease.sh endpoint with requests and checked the status code before building a DataFrame (231 rows).

Flatten

countryInfo was a nested dictionary. I extracted iso3, lat and long and dropped the original column.

Why: iso3 becomes the primary key for joins

Clean

Renamed columns to snake_case, removed 2 cruise ships (231 → 229 rows), converted epoch milliseconds to dates and dropped three “today” columns that were empty.

Why: cruise ships have no population or continent, so they would distort per-capita metrics

Load

Created a covid schema and loaded the table with to_sql, then altered the updated column to a proper date type.

Model

Split the table into two views: dim_country (who) and fact_cases (numbers), joined on iso3.

Why: a small star schema keeps queries readable

Data model

Dimension and fact views

sql · views
create or replace view covid.dim_country as (
    select iso3, country, continent, population
    from covid.database
);

create or replace view covid.fact_cases as (
    select iso3, cases, deaths, recovered, active, critical, tests
    from covid.database
);
Findings

Seven questions, answered in SQL

☠️

Most deaths

USA 1,219,487 · Brazil 711,380 · India 533,570.

🦠

Most cases

USA 111.8M · India 45.0M · France 40.1M.

📈

Population vs cases

Correlation of 0.37: a mild relationship. Population alone does not explain case counts.

🌍

Highest infection rate

Brunei at 77.17% of its population, a small country with high testing.

⚠️

Highest fatality rate

Yemen at 18.07%, which may reflect limited testing as much as severity.

🗺️

Share of world cases

Europe 35.96% · Asia 31.43% · North America 18.71%.

📊

Above-average fatality

75 countries sit above the unweighted average rate.

🏷️

Continent categories

High: Africa 2.01, South America 1.95, North America 1.29. Low: Europe 0.83, Asia 0.70, Oceania 0.22.

SQL & Python

Key code

python · API to PostgreSQL
# 1. pull live country data from the open disease.sh API
url = "https://disease.sh/v3/covid-19/countries"
data = requests.get(url).json()
df = pd.DataFrame(data)

# 2. flatten the nested countryInfo column
df["iso3"] = df["countryInfo"].str.get("iso3")
df["lat"]  = df["countryInfo"].str.get("lat")
df["long"] = df["countryInfo"].str.get("long")
df.drop("countryInfo", axis=1, inplace=True)

# 3. drop the two cruise ships (no iso3, no population, no continent)
df.dropna(inplace=True)
df.reset_index(drop=True, inplace=True)

# 4. epoch milliseconds -> real date
df["updated"] = pd.to_datetime(df["updated"], unit="ms").dt.normalize()

# 5. drop columns that are empty or almost empty
df.drop(["today_cases", "today_deaths", "today_recovered"], axis=1, inplace=True)

# 6. load into PostgreSQL (credentials stay outside the notebook)
df.to_sql("database", engine, schema="covid", if_exists="replace", index=False)
sql · analysis
-- highest fatality rate
select country, continent,
       round(100 * (deaths::numeric / cases), 2) as fatality_rate
from covid.fact_cases fc
join covid.dim_country dc on fc.iso3 = dc.iso3
order by fatality_rate desc
limit 5;

-- each continent's share of world cases (window function)
select continent,
       sum(cases) as total_cases,
       round(100 * sum(cases) / sum(sum(cases)) over (), 2) as share_pct
from covid.fact_cases fc
join covid.dim_country dc on fc.iso3 = dc.iso3
group by continent
order by total_cases desc;
sql · metric view
create view covid.metric_per_continent as
with fatality as (
    select continent,
           sum(cases)      as total_cases,
           sum(population) as total_population,
           sum(critical)   as total_critical_cases,
           sum(deaths)     as total_deaths,
           sum(recovered)  as total_recovered,
           round(100 * sum(deaths)::numeric / sum(cases), 2) as fatality_rate
    from covid.fact_cases fc
    join covid.dim_country dc on fc.iso3 = dc.iso3
    group by continent
),
avg_fatality as (
    select round(avg(fatality_rate), 2) as avg_fatality_rate from fatality
)
select continent, total_cases, total_population, total_critical_cases,
       total_deaths, total_recovered, fatality_rate,
       case when fatality_rate > avg_fatality_rate then 'High'
            when fatality_rate = avg_fatality_rate then 'Average'
            else 'Low' end as fatality_category
from fatality
cross join avg_fatality;
ℹ️ The API returns live data, so figures change each time the pipeline runs. Numbers above are from the 24 September 2026 snapshot.