From one fragile Excel file to a repeatable database pipeline
Many teams have a single Excel workbook that quietly runs a chunk of the business:
a Customer_Master.xlsx, an orders tracker, a stock log. It works, until it
doesn’t: someone overwrites a sheet, two versions diverge, or a formula error goes
unnoticed for months.
In this walkthrough we’ll migrate a multi-sheet Excel workbook into a proper relational database using Python, pandas and SQLAlchemy. We’ll build a small but reusable migration tool, not just a one-off script, and we’ll keep everything configuration-driven so you can adapt it to your own workbook.
All the code in this article lives in the public repository: excel-to-operational-database-python . Clone it and follow along, or point it at your own workbook once you’re comfortable with the pattern.
The working example: customers, products and orders
To keep things concrete, we’ll use a simple but realistic example: a small business workbook with three sheets:
- customers – who you sell to
- products – what you sell
- orders – which customer ordered which product, when and how much
In the GitHub repo, the sample workbook lives at
data/raw/sample_workbook.xlsx. Each sheet is a clean table:
Example sheet structure
| customer_id | name | joined_date | |
|---|---|---|---|
| 1 | Alice Smith | [email protected] | 2023-01-15 |
| 2 | Bob Jones | [email protected] | 2023-02-01 |
| 3 | Charlie Kim | [email protected] | 2023-03-10 |
| product_id | name | category | unit_price |
|---|---|---|---|
| 10 | Premium Widget | Widgets | 49.90 |
| 11 | Standard Widget | Widgets | 29.50 |
| 12 | Service Plan | Support | 9.99 |
| order_id | customer_id | product_id | order_date | quantity |
|---|---|---|---|---|
| 1001 | 1 | 10 | 2023-04-01 | 1 |
| 1002 | 1 | 12 | 2023-04-01 | 1 |
| 1003 | 2 | 11 | 2023-04-05 | 2 |
The demo workbook is deliberately simple so you can see the pattern clearly. Once you understand the flow, you can point the pipeline at your own multi-sheet workbook by updating a single YAML configuration file.
Designing a simple relational schema from the workbook
Before writing any Python, it’s worth sketching a relational schema that matches the meaning of the Excel sheets. Each sheet becomes a table, and we add primary keys and relationships explicitly.
customers
---------
customer_id (PK, int)
name (string, not null)
email (string, nullable)
joined_date (datetime, nullable)
products
--------
product_id (PK, int)
name (string, not null)
category (string, nullable)
unit_price (decimal/float, nullable)
orders
------
order_id (PK, int)
customer_id (FK -> customers.customer_id)
product_id (FK -> products.product_id)
order_date (datetime, nullable)
quantity (int, nullable)
This is the schema implemented in both the GitHub repo’s documentation and example configuration. It’s simple enough to follow by inspection, but it’s also “database shaped” enough that you can build realistic queries and reports on top of it.
A good starting point is to ask: “What is a row in this sheet actually describing?” If the answer is “one customer”, “one product” or “one order”, it probably wants its own table with a clear primary key and named foreign keys where sheets refer to each other.
Capturing the design in YAML configuration
Instead of hard-coding sheet names and column mappings in Python, the repo describes the migration in a YAML file:
# config/config.yaml
excel_path: "data/raw/sample_workbook.xlsx"
database_url: "sqlite:///data/db/app.db"
sheets:
customers:
table: customers
index_column: customer_id
dtypes:
customer_id: int
name: string
email: string
joined_date: date
products:
table: products
index_column: product_id
dtypes:
product_id: int
name: string
category: string
unit_price: decimal
orders:
table: orders
index_column: order_id
dtypes:
order_id: int
customer_id: int
product_id: int
order_date: date
quantity: int
The excel_path points at the workbook, database_url specifies
where to write the results (SQLite in this case), and the sheets block
maps each sheet to a target table, index column and target data types.
Treat the YAML file as the single source of truth for how your workbook maps to tables. As your Excel evolves, you can update the config instead of editing Python code in multiple places.
Repository structure: a reusable Excel→database migration tool
The GitHub repo is structured as a small, reusable Python package rather than a single ad-hoc script:
excel-to-operational-database-python/
src/
__init__.py
config.py
extract_excel.py
transform_clean.py
load_database.py
validate_data.py
schema_design.py
models.py
pipeline.py
cli.py
config/
config.example.yaml
config.yaml
data/
raw/
sample_workbook.xlsx
db/
app.db
intermediate/
...
docs/
schema-diagram.md
migration-notes.md
scripts/
run_example_migration.sh
tests/
test_transform_clean.py
test_validate_data.py
You can browse the full tree and code on GitHub: github.com/dorian-sotpyrc/excel-to-operational-database-python . The rest of this article walks through the most important pieces.
Step 1: Load configuration and Excel sheets
The first job is to load the YAML configuration and read the configured sheets from
the workbook. That logic lives in src/config.py and
src/extract_excel.py.
# src/config.py (excerpt)
from pathlib import Path
from typing import Any, Dict
import yaml
class ConfigError(Exception):
"""Raised when the configuration file is invalid."""
def load_config(path: str | Path) -> Dict[str, Any]:
cfg_path = Path(path)
if not cfg_path.exists():
raise ConfigError(f"Config file not found: {cfg_path}")
with cfg_path.open("r", encoding="utf8") as f:
cfg = yaml.safe_load(f) or {}
for key in ("excel_path", "database_url", "sheets"):
if key not in cfg:
raise ConfigError(f"Config missing required key: {key}")
excel_path = Path(cfg["excel_path"])
cfg["excel_path"] = excel_path
sheets = cfg.get("sheets", {})
if not isinstance(sheets, dict) or not sheets:
raise ConfigError("'sheets' must be a non-empty mapping")
return cfg
# src/extract_excel.py (excerpt)
from pathlib import Path
from typing import Dict
import pandas as pd
def extract_sheets(
excel_path: Path,
sheets_cfg: dict,
) -> Dict[str, pd.DataFrame]:
if not excel_path.exists():
raise FileNotFoundError(f"Excel file not found: {excel_path}")
frames: Dict[str, pd.DataFrame] = {}
for sheet_name in sheets_cfg.keys():
df = pd.read_excel(excel_path, sheet_name=sheet_name)
frames[sheet_name] = df
return frames
-
Describe your workbook in YAMLPoint
excel_pathat your workbook, choose adatabase_url, and define each sheet’s columns and types. -
Load the config safely
load_configvalidates that required keys exist and convertsexcel_pathinto a realPathobject. -
Extract sheets into DataFrames
extract_sheetsreads only the configured sheets into pandas DataFrames ready for cleaning.
Step 2: Clean and type your data with pandas
Real-world workbooks often have messy column names, mixed types and stray empty rows.
The transform_clean.py module normalises headers, coerces types according
to the config and drops fully empty rows.
# src/transform_clean.py (excerpt)
import pandas as pd
def _normalise_column_name(name: str) -> str:
return (
str(name)
.strip()
.lower()
.replace(" ", "_")
.replace("-", "_")
)
def _apply_type(df: pd.DataFrame, column: str, kind: str) -> pd.Series:
kind = kind.lower()
series = df[column]
if kind in {"int", "integer"}:
series = pd.to_numeric(series, errors="coerce").astype("Int64")
elif kind in {"float", "decimal", "number"}:
series = pd.to_numeric(series, errors="coerce")
elif kind in {"string", "str", "text"}:
series = series.astype("string").str.strip()
elif kind in {"bool", "boolean"}:
series = series.astype("boolean")
elif kind in {"date", "datetime"}:
series = pd.to_datetime(series, errors="coerce")
elif kind in {"category", "categorical"}:
series = series.astype("category")
return series
def clean_single_sheet(df_raw, sheet_name: str, cfg: dict):
df = df_raw.copy()
df.columns = [_normalise_column_name(c) for c in df.columns]
dtypes_cfg: dict[str, str] = cfg.get("dtypes", {}) or {}
if dtypes_cfg:
missing = [c for c in dtypes_cfg.keys() if c not in df.columns]
if missing:
raise ValueError(
f"Sheet '{sheet_name}' missing expected columns: {missing}"
)
df = df[list(dtypes_cfg.keys())]
for col, kind in dtypes_cfg.items():
df[col] = _apply_type(df, col, kind)
df = df.dropna(how="all").reset_index(drop=True)
index_col = cfg.get("index_column")
if index_col and index_col not in df.columns:
raise ValueError(
f"index_column '{index_col}' not found in cleaned sheet '{sheet_name}'"
)
return df, {"rows_clean": len(df)}
If you need to drop rows with missing IDs, cap outliers or normalise categories,
this is the right layer. Extend clean_single_sheet so the transforms
stay close to the configuration and data types.
Step 3: Load into SQLite with SQLAlchemy
Once the DataFrames are clean, we use SQLAlchemy to connect to SQLite and pandas’
to_sql to write each sheet into its target table.
# src/load_database.py (excerpt)
from typing import Dict, Any
import pandas as pd
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
def create_db_engine(database_url: str) -> Engine:
return create_engine(database_url, future=True)
def load_frames_to_db(
frames: Dict[str, pd.DataFrame],
sheets_cfg: Dict[str, Any],
engine: Engine,
if_exists: str = "replace",
) -> Dict[str, int]:
inserted_counts: Dict[str, int] = {}
for sheet_name, df in frames.items():
cfg = sheets_cfg.get(sheet_name, {})
table_name = cfg.get("table", sheet_name)
df.to_sql(
table_name,
engine,
if_exists=if_exists,
index=False,
)
inserted_counts[table_name] = len(df)
return inserted_counts
For the example we use SQLite because it’s easy to run locally and ship in a repo, but
you can point database_url at PostgreSQL, MySQL or another supported
database and keep the same pipeline.
Step 4: Validate that the migration actually worked
A successful script run is not the same as a correct migration. The repo includes a basic validation step that compares the expected row counts (from the load step) with the rows actually present in the database.
# src/validate_data.py (excerpt)
from typing import Dict, Any
from sqlalchemy import text
from sqlalchemy.engine import Engine
def validate_row_counts(
engine: Engine,
expected_rows: Dict[str, int],
) -> Dict[str, Any]:
results: Dict[str, Any] = {}
with engine.connect() as conn:
for table_name, expected in expected_rows.items():
count = conn.execute(
text(f"SELECT COUNT(*) FROM {table_name}")
).scalar_one()
results[table_name] = {
"expected": int(expected),
"actual": int(count),
"match": int(count) == int(expected),
}
return results
The pipeline.py module ties everything together and returns a summary
dictionary that includes transform stats, load stats, row-count results and a
high-level validation summary.
# src/pipeline.py (excerpt)
from .config import load_config
from .extract_excel import extract_sheets
from .transform_clean import transform_all
from .load_database import create_db_engine, load_frames_to_db
from .validate_data import validate_row_counts, summarise_validation
from .schema_design import describe_schema
def run_pipeline(config_path, dry_run: bool = False, intermediate_dir="data/intermediate"):
cfg = load_config(config_path)
excel_path = cfg["excel_path"]
database_url = cfg["database_url"]
sheets_cfg = cfg["sheets"]
frames_raw = extract_sheets(excel_path, sheets_cfg)
cleaned_frames, transform_stats = transform_all(
frames_raw,
sheets_cfg,
intermediate_dir=intermediate_dir,
)
if dry_run:
return {
"mode": "dry_run",
"excel_path": str(excel_path),
"database_url": database_url,
"schema": describe_schema(sheets_cfg),
"transform_stats": transform_stats,
"load_stats": {},
"row_counts": {},
"validation_summary": {},
}
engine = create_db_engine(database_url)
load_stats = load_frames_to_db(cleaned_frames, sheets_cfg, engine)
row_counts = validate_row_counts(engine, load_stats)
validation_summary = summarise_validation(row_counts)
return {
"mode": "full",
"excel_path": str(excel_path),
"database_url": database_url,
"schema": describe_schema(sheets_cfg),
"transform_stats": transform_stats,
"load_stats": load_stats,
"row_counts": row_counts,
"validation_summary": validation_summary,
}
Step 5: Run the CLI – dry-run first, then full migration
You don’t have to import anything in a notebook to run the pipeline. The repo exposes
a small command-line interface in src/cli.py so you can run migrations
directly from the terminal.
# src/cli.py (excerpt)
import argparse
import json
from .pipeline import run_pipeline
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Migrate an Excel workbook into a relational database using Python."
)
subparsers = parser.add_subparsers(dest="command", required=True)
migrate = subparsers.add_parser("migrate", help="Run the Excel -> DB migration pipeline.")
migrate.add_argument(
"--config", "-c",
type=str,
default="config/config.yaml",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Run extract/transform only, without writing to the database.",
)
migrate.add_argument(
"--json",
action="store_true",
help="Print a JSON summary instead of human-readable text.",
)
args = parser.parse_args(argv)
if args.command == "migrate":
result = run_pipeline(args.config, dry_run=args.dry_run)
if args.json:
print(json.dumps(result, indent=2, default=str))
return
# ... print human-readable summary ...
if __name__ == "__main__":
main()
With a virtual environment activated and dependencies installed, you can run:
# Clone the repo
git clone [email protected]:dorian-sotpyrc/excel-to-operational-database-python.git
cd excel-to-operational-database-python
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Dry-run (no database writes)
python -m src.cli migrate --config config/config.yaml --dry-run
# Full migration
python -m src.cli migrate --config config/config.yaml
The repository includes a helper script,
scripts/run_example_migration.sh, which runs a dry-run first and then
a full migration. It’s a good starting point for wiring this pipeline into your own
scheduler or deployment process.
Visualising row-count validation
For the sample workbook, each table ends up with three rows. The pipeline’s expected row counts and the counts queried from the database match exactly, which is what you want to see after a migration.
In the sample workbook, all three tables end up with three rows each, and the validation step confirms that expected and actual row counts match. For larger migrations, the same pattern helps you quickly spot missing or duplicated records.
Adapting the pipeline to your own Excel workbooks
Once you’ve run the example end-to-end, you can start pointing the tool at your own workbooks by updating configuration rather than rewriting code.
Run through this quick checklist before trusting your first migration:
- Each sheet you want to migrate has a clear “one row = one thing” meaning.
- There is a stable column you can use as a primary key for each table.
- Cross-sheet references (e.g. customer IDs in orders) are understood.
- You have a backup copy of the original workbook.
- The YAML configuration matches your sheet names and column names.
- A dry-run completes successfully and the transform stats look sensible.
Changing the config, not the code
To migrate a different workbook, you typically only need to:
- Update
excel_pathto point at your file. - Adjust the
sheetsmapping for any new or renamed sheets. - Update
dtypesif your columns/types differ.
The Python code remains unchanged. This is the main benefit of a configuration-first approach: your migration logic becomes a reusable internal tool instead of a tangle of one-off scripts.
Moving beyond SQLite
For production, you might want your operational database to live in PostgreSQL,
MySQL or SQL Server instead of a local SQLite file. In most cases, you only need to
change database_url in the YAML file:
-
PostgreSQL:
postgresql+psycopg2://user:password@host:5432/dbname -
MySQL:
mysql+pymysql://user:password@host:3306/dbname
Because the pipeline uses SQLAlchemy, most of the code doesn’t care which database you choose, as long as the required drivers are installed.
Extending validation rules
Row counts are a good baseline, but they’re not the whole story. You can build extra
migration checks into validate_data.py, for example:
- Verifying that all
orders.customer_idvalues exist incustomers. - Checking that primary keys are unique and not null.
- Flagging suspicious dates (far in the future or before your business existed).
- Enforcing that quantities are non-negative.
Common pitfalls when migrating Excel to a database
A few issues come up repeatedly when people move from spreadsheets to an operational database:
- Columns that mix numbers and text, making type conversion tricky.
- Merged cells or header rows that break the “one row per record” assumption.
- Hidden rows and columns with stale data that suddenly reappear in the database.
- IDs that are not truly unique, or that change over time, causing duplicate key issues.
- Dates stored as free-form text in inconsistent formats.
If your workbook relies heavily on merged cells, manually-coloured “section breaks” or formulas that blur the notion of a row as a single record, consider building a cleaner staging workbook or redesigning the underlying data structure before you migrate. A database will force you to be explicit.
Wrap-up: from one-off import to repeatable migration standard
Migrating from Excel to a database doesn’t have to mean rewriting everything or buying a heavyweight ETL tool. A small, configuration-driven Python pipeline can give you:
- An explicit schema and mapping from sheets to tables.
- A repeatable process for loading and cleaning data.
- Built-in validation so you can trust the results.
The repository behind this article is meant to be a starting point you can adapt for your own organisation: github.com/dorian-sotpyrc/excel-to-operational-database-python . Clone it, run the sample migration, then point it at the workbook that’s been running too much of your business for too long.
Related PLEX reading
References & further reading
-
pandas – DataFrame.to_sql documentation
Official reference for writing pandas DataFrames into SQL tables, including options for engine configuration and handling existing tables. -
Import Excel spreadsheets to Database with Pandas
A concise tutorial on usingread_excelandto_sqlfor one-off imports, useful as a minimal baseline. -
Building ETL pipeline for loading data from Excel to PostgreSQL with Python
Example of positioning Excel→database loading as an ETL pipeline, with an emphasis on automation and repeatable runs. -
Import from Excel to SQL Server using Python
SQL Server-focused guide that shows the same pattern with a different database backend and deployment environment. -
Top 10 Database Schema Design Best Practices
Modern overview of schema design principles, including keys, relationships and naming conventions to keep in mind when leaving Excel behind. -
Microsoft – Database design basics
Gentle introduction to relational database thinking from the Access/Office world, written for people who are comfortable in Excel. -
Best practices for spreadsheet data structure
Practical guidance on structuring spreadsheet data so that later migrations to a database or data warehouse are easier.