Integration · 11 min read

Sports Data Normalization

A developer guide to normalizing sports API responses from multiple providers into a consistent schema. Covers field mapping, type coercion, enum alignment, and ID resolution.

Last updated: August 2026

Overview

Every sports API provider has its own data model. API-Sports calls it a fixture, SportMonks calls it a match, and SportsDataIO calls it a game. Field names differ, nested structures vary, and status codes use different abbreviations. If you support multiple providers for failover or migration, you need a normalization layer.

Normalization is the process of transforming provider-specific responses into a common internal schema. This lets your application code work with a single data model regardless of which provider served the request, making failover, migration, and multi-provider architectures possible.

Architecture

A normalization layer sits between the API client and your application logic:

Normalization Flow

  Provider A API ──┐
  Provider B API ──┤── Adapter Layer ── Common Schema ── App Logic
  Provider C API ──┘

  Each adapter:
    1. Receives raw provider response
    2. Maps fields to common schema
    3. Coerces types (string → number, etc.)
    4. Normalizes enums (status codes, positions)
    5. Resolves IDs to internal IDs
    6. Returns a typed, predictable object

The common schema should be designed for your application's needs, not any single provider. Define it once, and each adapter is responsible for conforming to it.

Implementation

Common Schema Definition

Start by defining your common schema as TypeScript types or Python dataclasses. This is the contract every adapter must satisfy:

// schema.js — Common schema types

/**
 * Common fixture schema — all adapters must produce this shape.
 */
const FixtureStatus = {
  SCHEDULED: "SCHEDULED",
  LIVE: "LIVE",
  HALFTIME: "HALFTIME",
  FINISHED: "FINISHED",
  POSTPONED: "POSTPONED",
  CANCELLED: "CANCELLED",
} as const;

/**
 * @typedef {Object} NormalizedFixture
 * @property {string} id - Internal fixture ID
 * @property {string} externalId - Provider's fixture ID
 * @property {string} provider - Provider name
 * @property {string} leagueId - Internal league ID
 * @property {string} homeTeamId - Internal team ID
 * @property {string} awayTeamId - Internal team ID
 * @property {string} homeTeamName
 * @property {string} awayTeamName
 * @property {string} date - ISO 8601 UTC
 * @property {string} status - One of FixtureStatus
 * @property {number|null} minute - Current minute (if live)
 * @property {number} homeScore
 * @property {number} awayScore
 * @property {string|null} venue
 */
export { FixtureStatus };

Provider Adapters (JavaScript)

Each adapter maps a provider's response to the common schema. Here are adapters for API-Sports and SportMonks:

// adapters.js
import { FixtureStatus } from "./schema.js";

// Status code mapping for different providers
const STATUS_MAP_API_SPORTS = {
  "NS": FixtureStatus.SCHEDULED,   // Not Started
  "1H": FixtureStatus.LIVE,        // First Half
  "2H": FixtureStatus.LIVE,        // Second Half
  "HT": FixtureStatus.HALFTIME,    // Halftime
  "FT": FixtureStatus.FINISHED,    // Full Time
  "PST": FixtureStatus.POSTPONED,
  "CANC": FixtureStatus.CANCELLED,
  "ABD": FixtureStatus.CANCELLED,  // Abandoned
};

const STATUS_MAP_SPORTMONKS = {
  "NS": FixtureStatus.SCHEDULED,
  "LIVE": FixtureStatus.LIVE,
  "HT": FixtureStatus.HALFTIME,
  "FT": FixtureStatus.FINISHED,
  "PST": FixtureStatus.POSTPONED,
  "CANC": FixtureStatus.CANCELLED,
  "ABD": FixtureStatus.CANCELLED,
};

/**
 * API-Sports adapter
 */
export function normalizeApiSports(data) {
  return data.response.map((item) => ({
    id: `apisports-${item.fixture.id}`,
    externalId: String(item.fixture.id),
    provider: "api-sports",
    leagueId: String(item.league.id),
    homeTeamId: String(item.teams.home.id),
    awayTeamId: String(item.teams.away.id),
    homeTeamName: item.teams.home.name,
    awayTeamName: item.teams.away.name,
    date: item.fixture.date,  // Already ISO 8601 UTC
    status: STATUS_MAP_API_SPORTS[item.fixture.status.short]
      || FixtureStatus.SCHEDULED,
    minute: item.fixture.status.elapsed ?? null,
    homeScore: item.goals.home ?? 0,
    awayScore: item.goals.away ?? 0,
    venue: item.fixture.venue?.name ?? null,
  }));
}

/**
 * SportMonks adapter
 */
export function normalizeSportMonks(data) {
  const fixtures = Array.isArray(data.data) ? data.data : [data.data];
  return fixtures.map((item) => ({
    id: `sportmonks-${item.id}`,
    externalId: String(item.id),
    provider: "sportmonks",
    leagueId: String(item.league_id),
    homeTeamId: String(item.participants[0].id),
    awayTeamId: String(item.participants[1].id),
    homeTeamName: item.participants[0].name,
    awayTeamName: item.participants[1].name,
    date: item.starting_at,  // ISO 8601
    status: STATUS_MAP_SPORTMONKS[item.state?.short]
      || FixtureStatus.SCHEDULED,
    minute: item.scores?.localteam_score_90 !== undefined
      ? 90  // SportMonks doesn't expose minute directly
      : null,
    homeScore: item.scores?.localteam_score ?? 0,
    awayScore: item.scores?.visitorteam_score ?? 0,
    venue: item.venue?.name ?? null,
  }));
}

Python Dataclass Schema with Adapter

# normalizer.py
from dataclasses import dataclass, asdict
from enum import Enum
from datetime import datetime
from typing import Optional

class FixtureStatus(Enum):
    SCHEDULED = "SCHEDULED"
    LIVE = "LIVE"
    HALFTIME = "HALFTIME"
    FINISHED = "FINISHED"
    POSTPONED = "POSTPONED"
    CANCELLED = "CANCELLED"

@dataclass
class NormalizedFixture:
    id: str
    external_id: str
    provider: str
    league_id: str
    home_team_id: str
    away_team_id: str
    home_team_name: str
    away_team_name: str
    date: str          # ISO 8601 UTC
    status: str        # FixtureStatus value
    minute: Optional[int]
    home_score: int
    away_score: int
    venue: Optional[str]

# Provider-specific status maps
_STATUS_MAPS = {
    "api-sports": {
        "NS": FixtureStatus.SCHEDULED,
        "1H": FixtureStatus.LIVE, "2H": FixtureStatus.LIVE,
        "HT": FixtureStatus.HALFTIME, "FT": FixtureStatus.FINISHED,
        "PST": FixtureStatus.POSTPONED, "CANC": FixtureStatus.CANCELLED,
    },
    "apifootball": {
        "NS": FixtureStatus.SCHEDULED,
        "LIVE": FixtureStatus.LIVE, "HT": FixtureStatus.HALFTIME,
        "FT": FixtureStatus.FINISHED, "Postp.": FixtureStatus.POSTPONED,
        "CANC": FixtureStatus.CANCELLED,
    },
}

def normalize(data: dict, provider: str) -> list[NormalizedFixture]:
    """Normalize a provider response to the common schema."""
    status_map = _STATUS_MAPS.get(provider, {})

    if provider == "api-sports":
        items = data.get("response", [])
        return [
            NormalizedFixture(
                id=f"apisports-{item['fixture']['id']}",
                external_id=str(item["fixture"]["id"]),
                provider=provider,
                league_id=str(item["league"]["id"]),
                home_team_id=str(item["teams"]["home"]["id"]),
                away_team_id=str(item["teams"]["away"]["id"]),
                home_team_name=item["teams"]["home"]["name"],
                away_team_name=item["teams"]["away"]["name"],
                date=item["fixture"]["date"],
                status=status_map.get(
                    item["fixture"]["status"]["short"],
                    FixtureStatus.SCHEDULED
                ).value,
                minute=item["fixture"]["status"].get("elapsed"),
                home_score=item["goals"].get("home") or 0,
                away_score=item["goals"].get("away") or 0,
                venue=item["fixture"].get("venue", {}).get("name"),
            )
            for item in items
        ]

    elif provider == "apifootball":
        items = data if isinstance(data, list) else []
        return [
            NormalizedFixture(
                id=f"apifootball-{item['match_id']}",
                external_id=str(item["match_id"]),
                provider=provider,
                league_id=str(item.get("league_id", "")),
                home_team_id=str(item.get("match_hometeam_id", "")),
                away_team_id=str(item.get("match_awayteam_id", "")),
                home_team_name=item["match_hometeam_name"],
                away_team_name=item["match_awayteam_name"],
                date=item["match_start"],
                status=status_map.get(
                    item.get("match_status", "NS"),
                    FixtureStatus.SCHEDULED
                ).value,
                minute=None,
                home_score=int(item.get("match_hometeam_score") or 0),
                away_score=int(item.get("match_awayteam_score") or 0),
                venue=item.get("stadium"),
            )
            for item in items
        ]

    raise ValueError(f"Unknown provider: {provider}")

# Usage
raw = fetch_from_api("api-sports", endpoint="fixtures", league=39)
fixtures = normalize(raw, "api-sports")
for f in fixtures:
    print(f"{f.home_team_name} {f.home_score}-{f.away_score} {f.away_team_name}")

Best Practices

Design the common schema for your app, not the API

Your common schema should reflect what your application needs, not mirror any single provider. Include only the fields you actually use, and make the schema provider-agnostic.

Always coerce types defensively

Providers sometimes return null, empty strings, or strings where you expect numbers. Use parseInt() with defaults, nullish coalescing, and optional chaining to handle these inconsistencies gracefully.

Use prefixed IDs to avoid collisions

Provider IDs can collide (both API-Sports and SportMonks might have a fixture with ID 12345). Prefix internal IDs with the provider name (e.g., apisports-12345) to keep them unique across providers.

Test adapters with real API responses

Save real API responses as fixtures (test data files) and write unit tests that verify each adapter produces the correct common schema. This catches breaking changes when providers update their response formats.

Related Guides

Compare data formats across providers

See how different sports APIs structure their responses.