Overview
A well-designed sports database is the foundation of any sports application. Whether you are building a live score app, a fantasy platform, or a betting analytics dashboard, your schema determines how efficiently you can store, query, and update data from sports APIs.
The core challenge is modeling the relationships between leagues, seasons, teams, players, fixtures, and statistics while keeping the schema flexible enough to handle multiple sports. This guide uses PostgreSQL for relational examples and includes a document-store alternative for MongoDB.
Architecture
A sports database typically has these core entities:
Entity Relationship Overview
League (1) ──< Season (1) ──< Fixture (N) >── Team (2)
│ │
│ ├──< Match Event (N)
│ ├──< Lineup (N) >── Player (N)
│ └──< Match Statistic (N)
│
└──< Standing (N) >── Team (1)
Team (1) ──< Player (N) ──< Player Statistic (N)Each entity maps to a table in PostgreSQL. The key design decisions are: how to handle multi-sport data, how to store historical seasons, and how to index for fast fixture and standings queries.
Implementation
Core SQL Schema
Here is the SQL to create the core tables. This schema is designed for PostgreSQL but works with minor adjustments in MySQL:
-- leagues: Top-level competition entity
CREATE TABLE leagues (
id SERIAL PRIMARY KEY,
external_id VARCHAR(100) UNIQUE NOT NULL, -- API provider's ID
name VARCHAR(200) NOT NULL,
sport VARCHAR(50) NOT NULL, -- football, basketball, etc.
country VARCHAR(100),
logo_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- seasons: Tracks each season of a league
CREATE TABLE seasons (
id SERIAL PRIMARY KEY,
league_id INTEGER REFERENCES leagues(id) ON DELETE CASCADE,
year VARCHAR(10) NOT NULL, -- "2025-2026"
start_date DATE,
end_date DATE,
current BOOLEAN DEFAULT FALSE,
UNIQUE(league_id, year)
);
-- teams: Clubs or national teams
CREATE TABLE teams (
id SERIAL PRIMARY KEY,
external_id VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
short_name VARCHAR(50),
logo_url TEXT,
country VARCHAR(100),
founded INTEGER,
venue VARCHAR(200),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- fixtures: Individual matches
CREATE TABLE fixtures (
id SERIAL PRIMARY KEY,
external_id VARCHAR(100) UNIQUE NOT NULL,
league_id INTEGER REFERENCES leagues(id),
season_id INTEGER REFERENCES seasons(id),
home_team_id INTEGER REFERENCES teams(id),
away_team_id INTEGER REFERENCES teams(id),
fixture_date TIMESTAMPTZ NOT NULL,
status VARCHAR(20) DEFAULT 'NS', -- NS, 1H, HT, 2H, FT, etc.
minute INTEGER,
home_score INTEGER DEFAULT 0,
away_score INTEGER DEFAULT 0,
venue VARCHAR(200),
referee VARCHAR(200),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for common query patterns
CREATE INDEX idx_fixtures_date ON fixtures(fixture_date);
CREATE INDEX idx_fixtures_league_season ON fixtures(league_id, season_id);
CREATE INDEX idx_fixtures_teams ON fixtures(home_team_id, away_team_id);
CREATE INDEX idx_fixtures_status ON fixtures(status) WHERE status IN ('1H','2H','HT','LIVE');Players and Statistics Tables
-- players: Individual athletes
CREATE TABLE players (
id SERIAL PRIMARY KEY,
external_id VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
team_id INTEGER REFERENCES teams(id),
position VARCHAR(50), -- GK, DEF, MID, FWD
nationality VARCHAR(100),
birth_date DATE,
height VARCHAR(20),
weight VARCHAR(20),
photo_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- player_statistics: Per-match player stats
CREATE TABLE player_statistics (
id SERIAL PRIMARY KEY,
fixture_id INTEGER REFERENCES fixtures(id) ON DELETE CASCADE,
player_id INTEGER REFERENCES players(id),
team_id INTEGER REFERENCES teams(id),
rating DECIMAL(3,1),
goals INTEGER DEFAULT 0,
assists INTEGER DEFAULT 0,
yellow_cards INTEGER DEFAULT 0,
red_cards INTEGER DEFAULT 0,
minutes_played INTEGER DEFAULT 0,
passes_total INTEGER DEFAULT 0,
passes_accuracy DECIMAL(4,1),
shots_total INTEGER DEFAULT 0,
shots_on_target INTEGER DEFAULT 0,
UNIQUE(fixture_id, player_id)
);
-- standings: League table positions
CREATE TABLE standings (
id SERIAL PRIMARY KEY,
league_id INTEGER REFERENCES leagues(id),
season_id INTEGER REFERENCES seasons(id),
team_id INTEGER REFERENCES teams(id),
position INTEGER NOT NULL,
played INTEGER DEFAULT 0,
won INTEGER DEFAULT 0,
drawn INTEGER DEFAULT 0,
lost INTEGER DEFAULT 0,
goals_for INTEGER DEFAULT 0,
goals_against INTEGER DEFAULT 0,
points INTEGER DEFAULT 0,
form VARCHAR(10), -- "WWDLW"
UNIQUE(league_id, season_id, team_id)
);
CREATE INDEX idx_standings_league_season
ON standings(league_id, season_id, position);ORM Model (Python + SQLAlchemy)
For Python applications, here is the same schema using SQLAlchemy ORM models:
# models.py
from sqlalchemy import (
Column, Integer, String, Boolean, ForeignKey,
DateTime, Date, DECIMAL, UniqueConstraint, Index
)
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base()
class League(Base):
__tablename__ = "leagues"
id = Column(Integer, primary_key=True)
external_id = Column(String(100), unique=True, nullable=False)
name = Column(String(200), nullable=False)
sport = Column(String(50), nullable=False)
country = Column(String(100))
logo_url = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow)
seasons = relationship("Season", back_populates="league",
cascade="all, delete-orphan")
class Season(Base):
__tablename__ = "seasons"
id = Column(Integer, primary_key=True)
league_id = Column(Integer, ForeignKey("leagues.id"),
nullable=False)
year = Column(String(10), nullable=False)
start_date = Column(Date)
end_date = Column(Date)
current = Column(Boolean, default=False)
league = relationship("League", back_populates="seasons")
fixtures = relationship("Fixture", back_populates="season")
standings = relationship("Standing", back_populates="season")
__table_args__ = (
UniqueConstraint("league_id", "year"),
)
class Fixture(Base):
__tablename__ = "fixtures"
id = Column(Integer, primary_key=True)
external_id = Column(String(100), unique=True, nullable=False)
league_id = Column(Integer, ForeignKey("leagues.id"))
season_id = Column(Integer, ForeignKey("seasons.id"))
home_team_id = Column(Integer, ForeignKey("teams.id"))
away_team_id = Column(Integer, ForeignKey("teams.id"))
fixture_date = Column(DateTime, nullable=False)
status = Column(String(20), default="NS")
minute = Column(Integer)
home_score = Column(Integer, default=0)
away_score = Column(Integer, default=0)
season = relationship("Season", back_populates="fixtures")
home_team = relationship("Team", foreign_keys=[home_team_id])
away_team = relationship("Team", foreign_keys=[away_team_id])
player_stats = relationship("PlayerStatistic",
back_populates="fixture",
cascade="all, delete-orphan")
__table_args__ = (
Index("idx_fixtures_date", "fixture_date"),
Index("idx_fixtures_league_season", "league_id", "season_id"),
)Code Examples
Upserting API Data
When syncing data from a sports API, use upserts (insert or update) to handle both new and existing records efficiently:
// syncFixtures.js
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function upsertFixtures(apiFixtures, leagueId, seasonId) {
const client = await pool.connect();
try {
await client.query("BEGIN");
// Upsert teams first (fixtures depend on them)
for (const fixture of apiFixtures) {
for (const side of ["home", "away"]) {
const team = fixture.teams[side];
await client.query(
`INSERT INTO teams (external_id, name, logo_url)
VALUES ($1, $2, $3)
ON CONFLICT (external_id)
DO UPDATE SET name = $2, logo_url = $3`,
[team.id, team.name, team.logo]
);
}
}
// Upsert fixtures
for (const fixture of apiFixtures) {
const f = fixture.fixture;
await client.query(
`INSERT INTO fixtures
(external_id, league_id, season_id,
home_team_id, away_team_id,
fixture_date, status, minute,
home_score, away_score)
VALUES ($1, $2, $3,
(SELECT id FROM teams WHERE external_id = $4),
(SELECT id FROM teams WHERE external_id = $5),
$6, $7, $8, $9, $10)
ON CONFLICT (external_id)
DO UPDATE SET
status = $7,
minute = $8,
home_score = $9,
away_score = $10,
updated_at = NOW()`,
[
f.id, leagueId, seasonId,
fixture.teams.home.id, fixture.teams.away.id,
f.date, f.status.short, f.status.elapsed,
fixture.goals.home, fixture.goals.away,
]
);
}
await client.query("COMMIT");
console.log(`Synced ${apiFixtures.length} fixtures`);
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}Best Practices
Store external IDs for idempotent syncs
Every table should have an external_id column mapped to the API provider's ID. This makes upserts idempotent and allows you to switch providers without losing your data.
Use TIMESTAMPTZ for all dates
Always store fixture dates as UTC timestamps with timezone info. Convert to the user's local timezone at the presentation layer. See our timezone handling guide for details.
Index for your query patterns
The most common queries are by date range, by league and season, and by team. Create composite indexes for these patterns. Use partial indexes for live fixtures to keep the index small.
Partition historical data
If you accumulate years of fixture data, partition the fixtures table by season or year. This keeps active-season queries fast while historical data remains accessible without bloating indexes.
Related Guides
Sports Data Normalization
11 min readCaching Strategies for Sports API Data
12 min readTimezone Conversion for Sports Data
9 min readFind APIs with rich data for your schema
Compare providers by data depth, coverage, and export formats.