Engineering MetricsMarch 17, 2026 · 14 min read

How to Calculate DORA Metrics from GitHub Data (Step-by-Step)

Most teams have GitHub. Many teams do not yet have a dedicated engineering metrics platform. This guide walks through the exact API calls, data structures, and calculation logic you need to instrument all four DORA metrics from your GitHub organization data — including where GitHub alone is not sufficient and what you need to add.

What this guide covers

GitHub API endpoints for each DORA metric, the exact calculation logic including edge cases, Python code examples using the GitHub REST API, and where you need supplementary data sources (incident tools for MTTR).

Prerequisites and Setup

You will need a GitHub personal access token or GitHub App installation token with the following scopes: repo (for private repos), read:org(for organization-wide queries), and deployments. All examples below use the GitHub REST API v3 — the GraphQL API can be used for more efficient bulk queries once you understand the data model.

import requests
from datetime import datetime, timedelta
import statistics

GITHUB_TOKEN = "ghp_..."
OWNER = "your-org"
REPO = "your-repo"
HEADERS = {
    "Authorization": f"token {GITHUB_TOKEN}",
    "Accept": "application/vnd.github.v3+json"
}
BASE_URL = "https://api.github.com"

Metric 1: Deployment Frequency

Deployment frequency requires knowing when production deployments happened. GitHub provides two mechanisms: the Deployments API (requires your CI/CD to push deployment events) and GitHub Releases (for teams using release tags as deployment markers).

Option A: GitHub Deployments API (preferred)

If your CI/CD pipeline creates GitHub Deployment objects, this is the most accurate source. Each deployment has an environment field — filter for production.

def get_production_deployments(owner, repo, days=30):
    """Fetch successful production deployments in the last N days."""
    since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
    url = f"{BASE_URL}/repos/{owner}/{repo}/deployments"
    params = {"environment": "production", "per_page": 100}

    deployments = []
    while url:
        resp = requests.get(url, headers=HEADERS, params=params)
        resp.raise_for_status()
        data = resp.json()

        for deploy in data:
            created = deploy["created_at"]
            if created >= since:
                # Fetch the status to confirm it succeeded
                status_url = deploy["statuses_url"]
                status_resp = requests.get(status_url, headers=HEADERS)
                statuses = status_resp.json()
                if statuses and statuses[0]["state"] == "success":
                    deployments.append(deploy)

        url = resp.links.get("next", {}).get("url")

    return deployments

def calculate_deployment_frequency(deployments, days=30):
    """Returns deployments per day."""
    return len(deployments) / days

deploys = get_production_deployments(OWNER, REPO, days=30)
freq = calculate_deployment_frequency(deploys, days=30)
print(f"Deployment frequency: {freq:.2f} per day")

Option B: GitHub Releases as a proxy

If your team tags releases in GitHub but does not use the Deployments API, releases are a reasonable proxy — but they overcount if you create pre-release or draft releases, and undercount if you deploy hotfixes without creating a release.

def get_releases(owner, repo, days=30):
    since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
    url = f"{BASE_URL}/repos/{owner}/{repo}/releases"
    releases = []
    while url:
        resp = requests.get(url, headers=HEADERS, params={"per_page": 100})
        data = resp.json()
        for rel in data:
            # Skip drafts and pre-releases for deployment frequency
            if not rel["draft"] and not rel["prerelease"]:
                if rel["published_at"] >= since:
                    releases.append(rel)
        url = resp.links.get("next", {}).get("url")
    return releases

Metric 2: Lead Time for Changes

Lead time is the elapsed time from PR merge to production deployment. This requires correlating PR merge timestamps with deployment timestamps via the commit SHA.

def get_merged_prs(owner, repo, days=30):
    """Fetch PRs merged in the last N days."""
    since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
    url = f"{BASE_URL}/repos/{owner}/{repo}/pulls"
    params = {"state": "closed", "sort": "updated", "per_page": 100}

    merged_prs = []
    while url:
        resp = requests.get(url, headers=HEADERS, params=params)
        data = resp.json()
        for pr in data:
            if pr.get("merged_at") and pr["merged_at"] >= since:
                merged_prs.append({
                    "number": pr["number"],
                    "merged_at": pr["merged_at"],
                    "merge_commit_sha": pr["merge_commit_sha"],
                })
        url = resp.links.get("next", {}).get("url")
    return merged_prs

def calculate_lead_times(merged_prs, deployments):
    """For each PR, find the first deployment that contains its merge commit."""
    lead_times = []

    # Build a lookup of deployment SHA -> deployment timestamp
    deploy_by_sha = {}
    for d in deployments:
        deploy_by_sha[d["sha"]] = d["created_at"]

    for pr in merged_prs:
        sha = pr["merge_commit_sha"]
        if sha in deploy_by_sha:
            merged = datetime.fromisoformat(pr["merged_at"].rstrip("Z"))
            deployed = datetime.fromisoformat(deploy_by_sha[sha].rstrip("Z"))
            lead_time_hours = (deployed - merged).total_seconds() / 3600
            if lead_time_hours >= 0:
                lead_times.append(lead_time_hours)

    if lead_times:
        # Use median — lead time distributions are right-skewed
        return statistics.median(lead_times)
    return None

prs = get_merged_prs(OWNER, REPO, days=30)
median_lt = calculate_lead_times(prs, deploys)
print(f"Median lead time: {median_lt:.1f} hours")

Important edge case: GitHub's Deployments API stores the commit SHA of the deployment, which may be a merge commit rather than the PR's merge commit SHA. If your pipeline rebases or squash-merges, you will need to walk the commit graph to find which deployment contains a given PR's commit. The simplest workaround: store the PR number or branch name in the deployment description field so you can match directly.

Metric 3: Change Failure Rate

CFR requires identifying which deployments were failures. The definition varies by team, but the most reliable approach combines two signals: deployment status and incident correlation.

Signal 1: Rollback/hotfix detection from PR patterns

import re

ROLLBACK_PATTERNS = [
    r"^revert",
    r"^rollback",
    r"^hotfix",
    r"^fix.*production",
    r"^emergency",
]

def is_unplanned_deployment(pr_title, branch_name):
    """Returns True if the PR looks like a rollback or hotfix."""
    text = (pr_title + " " + branch_name).lower()
    return any(re.search(pat, text) for pat in ROLLBACK_PATTERNS)

def calculate_cfr(merged_prs):
    """Basic CFR from PR title pattern matching."""
    if not merged_prs:
        return 0
    failed = sum(
        1 for pr in merged_prs
        if is_unplanned_deployment(
            pr.get("title", ""),
            pr.get("head", {}).get("ref", "")
        )
    )
    return (failed / len(merged_prs)) * 100

Signal 2: Deployment status failures

def get_failed_deployments(owner, repo, days=30):
    """Find deployments that ended in failure status."""
    all_deploys = get_production_deployments.__wrapped__(owner, repo, days)
    failed = []
    for deploy in all_deploys:
        status_resp = requests.get(deploy["statuses_url"], headers=HEADERS)
        statuses = status_resp.json()
        if statuses and statuses[0]["state"] in ("failure", "error"):
            failed.append(deploy)
    return failed

Metric 4: MTTR — What GitHub Cannot Give You

MTTR requires incident data: when the incident was detected and when service was restored. GitHub does not have this natively. You need to pull data from your incident management tool.

PagerDuty

PD_TOKEN = "your-pagerduty-token"
PD_HEADERS = {"Authorization": f"Token token={PD_TOKEN}"}

def get_pagerduty_incidents(days=30):
    since = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
    url = "https://api.pagerduty.com/incidents"
    params = {
        "since": since,
        "statuses[]": ["resolved"],
        "limit": 100,
    }
    resp = requests.get(url, headers=PD_HEADERS, params=params)
    return resp.json().get("incidents", [])

def calculate_mttr_hours(incidents):
    """Mean time from incident created to resolved."""
    durations = []
    for inc in incidents:
        created = datetime.fromisoformat(inc["created_at"].rstrip("Z"))
        resolved = datetime.fromisoformat(
            inc["last_status_change_at"].rstrip("Z")
        )
        hours = (resolved - created).total_seconds() / 3600
        durations.append(hours)
    return statistics.mean(durations) if durations else None

incidents = get_pagerduty_incidents(days=30)
mttr = calculate_mttr_hours(incidents)
print(f"Mean MTTR: {mttr:.1f} hours")

OpsGenie / incident.io

OpsGenie exposes the same pattern through its Alerts API: GET /v2/alerts?status=closed&createdAt>=.... Each alert has acreatedAt and a closedAt field. incident.io uses a similar REST API with /v1/incidents?status=resolved.

Putting It All Together: A Weekly DORA Report

def weekly_dora_report(owner, repo, days=7):
    deploys = get_production_deployments(owner, repo, days)
    prs = get_merged_prs(owner, repo, days)
    incidents = get_pagerduty_incidents(days)

    freq = calculate_deployment_frequency(deploys, days)
    lead_time = calculate_lead_times(prs, deploys)
    cfr = calculate_cfr(prs)
    mttr = calculate_mttr_hours(incidents)

    print(f"=== DORA Report ({days}d) ===")
    print(f"Deployment Frequency: {freq:.2f}/day")
    print(f"Lead Time (median):   {lead_time:.1f}h" if lead_time else "Lead Time: insufficient data")
    print(f"Change Failure Rate:  {cfr:.1f}%")
    print(f"MTTR (mean):          {mttr:.1f}h" if mttr else "MTTR: no incidents")

weekly_dora_report(OWNER, REPO, days=30)

Common Calculation Mistakes

Before you trust these numbers, check for the most common errors teams make when implementing DORA from scratch:

  • Counting PRs instead of deployments. A PR merged to main is not a deployment. If you deploy once per day regardless of how many PRs merged, your deployment frequency is once per day — not once per PR.
  • Using UTC timestamps without timezone adjustment. A Friday 5pm EST deploy is a Friday deploy risk. In UTC, it appears as a Saturday midnight event. Always convert to the team's local timezone before day-of-week analysis.
  • Measuring repository frequency, not service frequency. A monorepo that deploys five services from a single merge produces five deployment events. Count them separately per service, not once per repo push.
  • Using mean instead of median for lead time. A single large refactor PR with a 2-week lead time skews the mean dramatically. Median is the correct central tendency measure for skewed distributions.

Skip the implementation — connect GitHub in 5 minutes

The code above is the right foundation for a custom implementation. If you want DORA metrics today without building and maintaining the pipeline, Koalr handles all of this automatically — including the incident correlation, SHA matching, and timezone normalization that make the data trustworthy.

Get accurate DORA metrics without writing the pipeline yourself

Koalr connects to GitHub, PagerDuty, OpsGenie, and incident.io and calculates all four DORA metrics automatically — with correct SHA correlation, timezone handling, and service-level segmentation out of the box.