Skip to content

API Reference

Live Tournament Data

pga_tour_api.pga_current_tournament(tour: str = 'R') -> str

Return this week's tournament ID for a tour.

Reads defaultTournaments from the PGA Tour web-config document (the same source the frontend uses). Raises PgaTourError if that tour has no default event.

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'

Returns:

Type Description
str

Tournament ID (e.g. "R2026027").

Source code in src/pga_tour_api/client.py
def pga_current_tournament(tour: str = "R") -> str:
    """Return this week's tournament ID for a tour.

    Reads ``defaultTournaments`` from the PGA Tour web-config document
    (the same source the frontend uses). Raises ``PgaTourError`` if
    that tour has no default event.

    Args:
        tour: Tour code. Defaults to ``"R"``.

    Returns:
        Tournament ID (e.g. ``"R2026027"``).
    """
    _validate_tour(tour)
    cfg = config_request("web-config")
    entries = _safe_get(cfg or {}, "defaultTournaments", tour, default=[]) or []
    if not entries:
        raise PgaTourError(f"no default tournament for tour {tour!r}")
    first = entries[0] if isinstance(entries[0], dict) else {}
    tid = first.get("id") or first.get("leaderboardId")
    if not tid:
        raise PgaTourError(f"default tournament for tour {tour!r} has no id")
    return str(tid)

pga_tour_api.pga_leaderboard(tournament_id: str) -> pd.DataFrame

Get tournament leaderboard.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required

Returns:

Type Description
DataFrame

DataFrame with one row per player.

Source code in src/pga_tour_api/client.py
def pga_leaderboard(tournament_id: str) -> pd.DataFrame:
    """Get tournament leaderboard.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").

    Returns:
        DataFrame with one row per player.
    """
    data = graphql_request(
        "LeaderboardCompressedV3",
        {"leaderboardCompressedV3Id": tournament_id},
    )
    payload = _safe_get(data, "leaderboardCompressedV3", "payload")
    if not payload:
        return pd.DataFrame()

    parsed = decompress_payload(payload)
    players = parsed.get("players", [])
    if not players:
        return pd.DataFrame()

    rows = []
    for p in players:
        player = p.get("player") or {}
        scoring = p.get("scoringData") or {}
        rounds = scoring.get("rounds") or []
        row = {
            "player_id": player.get("id"),
            "first_name": player.get("firstName"),
            "last_name": player.get("lastName"),
            "display_name": player.get("displayName"),
            "short_name": player.get("shortName"),
            "country": player.get("country"),
            "country_flag": player.get("countryFlag"),
            "amateur": player.get("amateur"),
            "position": scoring.get("position"),
            "total": scoring.get("total"),
            "total_sort": _to_float(scoring.get("totalSort")),
            "thru": scoring.get("thru"),
            "score": scoring.get("score"),
            "score_sort": _to_float(scoring.get("scoreSort")),
            "current_round": scoring.get("currentRound"),
            "player_state": scoring.get("playerState"),
            "tee_time": _epoch_ms_to_datetime(scoring.get("teeTime")),
            "course_id": scoring.get("courseId"),
            "group_number": scoring.get("groupNumber"),
            "back_nine": scoring.get("backNine"),
            "movement_direction": scoring.get("movementDirection"),
            "movement_amount": scoring.get("movementAmount"),
            "total_strokes": scoring.get("totalStrokes"),
            "official": scoring.get("official"),
            "projected": scoring.get("projected"),
        }
        for i, r in enumerate(rounds, 1):
            row[f"round_{i}"] = r
        rows.append(row)

    df = pd.DataFrame(rows)
    if not df.empty:
        df["total_sort"] = pd.to_numeric(df["total_sort"], errors="coerce")
        df["score_sort"] = pd.to_numeric(df["score_sort"], errors="coerce")
        df.attrs["format_type"] = parsed.get("formatType")
        df.attrs["tournament_status"] = parsed.get("tournamentStatus")
        df.attrs["bubble_pill"] = parsed.get("bubblePill")
    return df

pga_tour_api.pga_field(tournament_id: str, *, include_withdrawn: bool = True) -> pd.DataFrame

Get the tournament field.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026027").

required
include_withdrawn bool

Include withdrawn players.

True

Returns:

Type Description
DataFrame

DataFrame with one row per player (field + alternates).

Source code in src/pga_tour_api/client.py
def pga_field(
    tournament_id: str,
    *,
    include_withdrawn: bool = True,
) -> pd.DataFrame:
    """Get the tournament field.

    Args:
        tournament_id: Tournament ID (e.g., ``"R2026027"``).
        include_withdrawn: Include withdrawn players.

    Returns:
        DataFrame with one row per player (field + alternates).
    """
    data = graphql_request(
        "Field",
        {
            "fieldId": tournament_id,
            "includeWithdrawn": include_withdrawn,
        },
    )
    field = data.get("field") or {}
    rows = [_field_player_row(p) for p in (field.get("players") or []) if isinstance(p, dict)]
    rows.extend(
        _field_player_row(p, alternate=True)
        for p in (field.get("alternates") or [])
        if isinstance(p, dict)
    )
    if not rows:
        return pd.DataFrame()
    df = pd.DataFrame(rows)
    df.attrs["tournament_name"] = field.get("tournamentName")
    df.attrs["last_updated"] = field.get("lastUpdated")
    return df

pga_tour_api.pga_field_stats(tournament_id: str, field_stat_type: str = 'CURRENT_FORM') -> pd.DataFrame

Get field-level current-form or course-fit stats.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID.

required
field_stat_type str

"CURRENT_FORM" or "COURSE_FIT".

'CURRENT_FORM'

Returns:

Type Description
DataFrame

DataFrame with one row per player. Shape varies by type.

Source code in src/pga_tour_api/client.py
def pga_field_stats(
    tournament_id: str,
    field_stat_type: str = "CURRENT_FORM",
) -> pd.DataFrame:
    """Get field-level current-form or course-fit stats.

    Args:
        tournament_id: Tournament ID.
        field_stat_type: ``"CURRENT_FORM"`` or ``"COURSE_FIT"``.

    Returns:
        DataFrame with one row per player. Shape varies by type.
    """
    data = graphql_request(
        "FieldStats",
        {"tournamentId": tournament_id, "fieldStatType": field_stat_type},
    )
    payload = data.get("fieldStats") or {}
    players = payload.get("players") or []
    if not players:
        return pd.DataFrame()

    headers = payload.get("statHeaders") or []
    rows = []
    for p in players:
        if not isinstance(p, dict):
            continue
        row: dict[str, Any] = {
            "player_id": p.get("playerId"),
            "field_stat_type": payload.get("fieldStatType") or field_stat_type,
            "total_rounds": p.get("totalRounds"),
            "score": p.get("score"),
        }
        if p.get("__typename") == "FieldStatCurrentForm":
            results = p.get("tournamentResults") or []
            row["recent_events"] = len(results)
            if results and isinstance(results[0], dict):
                row["last_event"] = results[0].get("name")
                row["last_position"] = results[0].get("position")
            sg = p.get("strokesGained") or []
            sg_headers = p.get("strokesGainedHeader") or []
            for i, item in enumerate(sg):
                if not isinstance(item, dict):
                    continue
                label = sg_headers[i] if i < len(sg_headers) else item.get("statId") or f"sg_{i}"
                row[f"sg_{make_unique_snake([str(label)])[0]}"] = item.get("statValue")
        else:
            stats = p.get("stats") or []
            snake_headers = make_unique_snake([str(h) for h in headers]) if headers else []
            for i, item in enumerate(stats):
                if not isinstance(item, dict):
                    continue
                col = snake_headers[i] if i < len(snake_headers) else f"stat_{i}"
                row[col] = item.get("statValue")
                row[f"{col}_rank"] = item.get("statRank")
        rows.append(row)

    if not rows:
        return pd.DataFrame()
    return pd.DataFrame(rows)

pga_tour_api.pga_leaderboard_holes(tournament_id: str, round: int | None = None) -> pd.DataFrame

Get hole-by-hole scores for the whole field.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID.

required
round int | None

Round number. Defaults to the API's current round.

None

Returns:

Type Description
DataFrame

DataFrame with one row per player per hole.

Source code in src/pga_tour_api/client.py
def pga_leaderboard_holes(
    tournament_id: str,
    round: int | None = None,
) -> pd.DataFrame:
    """Get hole-by-hole scores for the whole field.

    Args:
        tournament_id: Tournament ID.
        round: Round number. Defaults to the API's current round.

    Returns:
        DataFrame with one row per player per hole.
    """
    variables: dict[str, Any] = {"tournamentId": tournament_id}
    if round is not None:
        variables["round"] = int(round)

    data = graphql_request("LeaderboardHoleByHole", variables)
    board = data.get("leaderboardHoleByHole") or {}
    players = board.get("playerData") or []
    if not players:
        return pd.DataFrame()

    rows = []
    for p in players:
        if not isinstance(p, dict):
            continue
        player_id = p.get("playerId")
        out = p.get("out")
        inn = p.get("in")
        total = p.get("total")
        total_to_par = p.get("totalToPar")
        course_id = p.get("courseId")
        course_code = p.get("courseCode")
        scores = p.get("scores") or []
        if not scores:
            rows.append({
                "player_id": player_id,
                "out": out,
                "in": inn,
                "total": total,
                "total_to_par": total_to_par,
                "course_id": course_id,
                "course_code": course_code,
            })
            continue
        for hole in scores:
            if not isinstance(hole, dict):
                continue
            rows.append({
                "player_id": player_id,
                "hole_number": hole.get("holeNumber"),
                "par": hole.get("par"),
                "yardage": hole.get("yardage"),
                "score": hole.get("score"),
                "status": hole.get("status"),
                "round_score": hole.get("roundScore"),
                "sequence_number": hole.get("sequenceNumber"),
                "out": out,
                "in": inn,
                "total": total,
                "total_to_par": total_to_par,
                "course_id": course_id,
                "course_code": course_code,
            })

    if not rows:
        return pd.DataFrame()
    df = pd.DataFrame(rows)
    df.attrs["tournament_id"] = board.get("tournamentId")
    df.attrs["tournament_name"] = board.get("tournamentName")
    df.attrs["current_round"] = board.get("currentRound")
    return df

pga_tour_api.pga_current_leaders(tournament_id: str) -> pd.DataFrame

Get current leaders snapshot (top 15).

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required

Returns:

Type Description
DataFrame

DataFrame of current leaders.

Source code in src/pga_tour_api/client.py
def pga_current_leaders(tournament_id: str) -> pd.DataFrame:
    """Get current leaders snapshot (top 15).

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").

    Returns:
        DataFrame of current leaders.
    """
    data = graphql_request(
        "CurrentLeadersCompressed",
        {"tournamentId": tournament_id},
    )
    payload = _safe_get(data, "currentLeadersCompressed", "payload")
    if not payload:
        return pd.DataFrame()

    parsed = decompress_payload(payload)
    players = parsed.get("players", [])
    if not players:
        return pd.DataFrame()

    # Response is a flat list of dicts
    if isinstance(players, list) and isinstance(players[0], dict):
        rows = []
        for p in players:
            rows.append({
                "player_id": p.get("id"),
                "first_name": p.get("firstName"),
                "last_name": p.get("lastName"),
                "display_name": p.get("displayName"),
                "country": p.get("country"),
                "position": p.get("position"),
                "total_score": p.get("totalScore"),
                "thru": p.get("thru"),
                "round_score": p.get("roundScore"),
                "round_header": p.get("roundHeader"),
                "player_state": p.get("playerState"),
                "back_nine": p.get("backNine"),
                "group_number": p.get("groupNumber"),
            })
        return pd.DataFrame(rows)

    return pd.DataFrame(players)

pga_tour_api.pga_tee_times(tournament_id: str) -> pd.DataFrame

Get tee times for a tournament.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required

Returns:

Type Description
DataFrame

DataFrame with one row per player per round.

Source code in src/pga_tour_api/client.py
def pga_tee_times(tournament_id: str) -> pd.DataFrame:
    """Get tee times for a tournament.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").

    Returns:
        DataFrame with one row per player per round.
    """
    data = graphql_request(
        "TeeTimesCompressedV2",
        {"teeTimesCompressedV2Id": tournament_id},
    )
    payload = _safe_get(data, "teeTimesCompressedV2", "payload")
    if not payload:
        return pd.DataFrame()

    parsed = decompress_payload(payload)
    rounds = parsed.get("rounds") or []
    if not rounds:
        return pd.DataFrame()

    frames: list[pd.DataFrame] = []
    for rnd in rounds:
        round_num = rnd.get("roundInt")
        round_display = rnd.get("roundDisplay")
        round_status = rnd.get("roundStatus")
        for group in rnd.get("groups") or []:
            players = group.get("players") or []
            n = len(players)
            if n == 0:
                continue
            tee_time = _epoch_ms_to_datetime(group.get("teeTime"))
            group_number = group.get("groupNumber")
            start_tee = group.get("startTee")
            back_nine = group.get("backNine", False)
            frames.append(pd.DataFrame({
                "round_number": [round_num] * n,
                "round_display": [round_display] * n,
                "round_status": [round_status] * n,
                "group_number": [group_number] * n,
                "tee_time": [tee_time] * n,
                "start_tee": [start_tee] * n,
                "back_nine": [back_nine] * n,
                "player_id": [p.get("id") for p in players],
                "first_name": [p.get("firstName") for p in players],
                "last_name": [p.get("lastName") for p in players],
                "display_name": [p.get("displayName") for p in players],
                "country": [p.get("country") for p in players],
            }))

    if not frames:
        return pd.DataFrame()
    return pd.concat(frames, ignore_index=True)

pga_tour_api.pga_scorecard(tournament_id: str, player_id: str) -> pd.DataFrame

Get hole-by-hole scorecard.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required
player_id str

Player ID (e.g., "39971").

required

Returns:

Type Description
DataFrame

DataFrame with one row per hole per round.

Source code in src/pga_tour_api/client.py
def pga_scorecard(tournament_id: str, player_id: str) -> pd.DataFrame:
    """Get hole-by-hole scorecard.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").
        player_id: Player ID (e.g., "39971").

    Returns:
        DataFrame with one row per hole per round.
    """
    data = graphql_request(
        "ScorecardCompressedV3",
        {"tournamentId": tournament_id, "playerId": player_id},
    )
    payload = _safe_get(data, "scorecardCompressedV3", "payload")
    if not payload:
        return pd.DataFrame()

    parsed = decompress_payload(payload)
    round_scores = parsed.get("roundScores", [])
    if not round_scores:
        return pd.DataFrame()

    rows = []
    for rnd in round_scores:
        round_num = rnd.get("roundNumber")
        course_name = rnd.get("courseName")
        round_total = rnd.get("total")
        round_stp = rnd.get("scoreToPar")

        for nine_key in ("firstNine", "secondNine"):
            nine = rnd.get(nine_key, {})
            if not nine:
                continue
            for hole in nine.get("holes", []):
                rows.append({
                    "round_number": round_num,
                    "hole_number": hole.get("holeNumber"),
                    "par": hole.get("par"),
                    "score": hole.get("score"),
                    "status": hole.get("status"),
                    "yardage": hole.get("yardage"),
                    "round_score": hole.get("roundScore"),
                    "sequence_number": hole.get("sequenceNumber"),
                    "course_name": course_name,
                    "round_total": round_total,
                    "round_score_to_par": round_stp,
                })

    return pd.DataFrame(rows)

pga_tour_api.pga_shot_details(tournament_id: str, player_id: str, round: int, *, include_radar: bool = False) -> pd.DataFrame

Get shot-level tracking data with coordinates.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required
player_id str

Player ID (e.g., "39971").

required
round int

Round number (1-4).

required
include_radar bool

Include radar data.

False

Returns:

Type Description
DataFrame

DataFrame with one row per stroke.

Source code in src/pga_tour_api/client.py
def pga_shot_details(
    tournament_id: str,
    player_id: str,
    round: int,
    *,
    include_radar: bool = False,
) -> pd.DataFrame:
    """Get shot-level tracking data with coordinates.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").
        player_id: Player ID (e.g., "39971").
        round: Round number (1-4).
        include_radar: Include radar data.

    Returns:
        DataFrame with one row per stroke.
    """
    data = graphql_request(
        "shotDetailsV4Compressed",
        {
            "tournamentId": tournament_id,
            "playerId": player_id,
            "round": int(round),
            "includeRadar": include_radar,
        },
    )
    payload = _safe_get(data, "shotDetailsV4Compressed", "payload")
    if not payload:
        return pd.DataFrame()

    parsed = decompress_payload(payload)
    if not isinstance(parsed, dict):
        return pd.DataFrame()
    holes = parsed.get("holes") or []
    if not isinstance(holes, list) or len(holes) == 0:
        return pd.DataFrame()

    rows = []
    for hole in holes:
        if not isinstance(hole, dict):
            continue
        hole_num = hole.get("holeNumber")
        par = hole.get("par")
        yardage = hole.get("yardage")
        hole_status = hole.get("status")
        hole_score = hole.get("score")

        for stroke in hole.get("strokes") or []:
            row = {
                "hole_number": hole_num,
                "par": par,
                "yardage": yardage,
                "hole_status": hole_status,
                "hole_score": hole_score,
                "stroke_number": stroke.get("strokeNumber"),
                "play_by_play": stroke.get("playByPlay"),
                "distance": stroke.get("distance"),
                "distance_remaining": stroke.get("distanceRemaining"),
                "stroke_type": stroke.get("strokeType"),
                "from_location": stroke.get("fromLocation"),
                "to_location": stroke.get("toLocation"),
                "from_location_code": stroke.get("fromLocationCode"),
                "to_location_code": stroke.get("toLocationCode"),
                "final_stroke": stroke.get("finalStroke"),
            }
            # Flatten coordinate data from overview
            overview = stroke.get("overview", {})
            if isinstance(overview, dict):
                for coord_key in ("leftToRightCoords", "bottomToTopCoords"):
                    coords = overview.get(coord_key, {})
                    if isinstance(coords, dict):
                        for point in ("fromCoords", "toCoords"):
                            pt = coords.get(point, {})
                            if isinstance(pt, dict):
                                prefix = f"{coord_key}.{point}"
                                for k in ("x", "y", "tourcastX", "tourcastY", "tourcastZ"):
                                    row[f"{prefix}.{k}"] = pt.get(k)
            rows.append(row)

    if not rows:
        return pd.DataFrame()
    return pd.DataFrame(rows)

pga_tour_api.pga_odds(tournament_id: str) -> pd.DataFrame

Get odds to win for a tournament.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required

Returns:

Type Description
DataFrame

DataFrame with player odds data.

Source code in src/pga_tour_api/client.py
def pga_odds(tournament_id: str) -> pd.DataFrame:
    """Get odds to win for a tournament.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").

    Returns:
        DataFrame with player odds data.
    """
    data = graphql_request(
        "oddsToWinCompressed",
        {"tournamentId": tournament_id},
    )
    payload = _safe_get(data, "oddsToWinCompressed", "payload")
    if not payload:
        return pd.DataFrame()

    parsed = decompress_payload(payload)

    players: list = []
    if isinstance(parsed, list):
        players = parsed
    elif isinstance(parsed, dict):
        for key in ("players", "odds", "rows"):
            if isinstance(parsed.get(key), list):
                players = parsed[key]
                break

    if not players:
        return pd.DataFrame()

    rows = []
    for p in players:
        if not isinstance(p, dict):
            continue
        rows.append({
            "player_id": p.get("playerId") or p.get("id"),
            "display_name": p.get("displayName"),
            "odds": p.get("odds"),
            "odds_sort": p.get("oddsSort"),
            "odds_direction": p.get("oddsDirection"),
            "option_id": p.get("optionId"),
            "url": p.get("url"),
        })
    return pd.DataFrame(rows)

pga_tour_api.pga_odds_markets(tournament_id: str) -> pd.DataFrame

Get the available betting-market catalog for a tournament.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID.

required

Returns:

Type Description
DataFrame

DataFrame of market types (To Win, matchups, finishes, …).

Source code in src/pga_tour_api/client.py
def pga_odds_markets(tournament_id: str) -> pd.DataFrame:
    """Get the available betting-market catalog for a tournament.

    Args:
        tournament_id: Tournament ID.

    Returns:
        DataFrame of market types (To Win, matchups, finishes, …).
    """
    resp = rest_request(f"odds/tournament/{tournament_id}")
    markets = resp.get("availableMarkets") or []
    if not markets:
        return pd.DataFrame()
    rows = []
    for m in markets:
        if not isinstance(m, dict):
            continue
        rows.append({
            "market_id": m.get("id"),
            "name": m.get("name"),
            "display_name": m.get("displayName"),
            "market_type": m.get("marketType"),
            "book": m.get("book"),
        })
    return pd.DataFrame(rows)

pga_tour_api.pga_player_odds(tournament_id: str, player_id: str) -> pd.DataFrame

Get FanDuel markets for one player in a tournament.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID.

required
player_id str

Player ID.

required

Returns:

Type Description
DataFrame

DataFrame of market lines (finish, matchups, props, …).

Source code in src/pga_tour_api/client.py
def pga_player_odds(tournament_id: str, player_id: str) -> pd.DataFrame:
    """Get FanDuel markets for one player in a tournament.

    Args:
        tournament_id: Tournament ID.
        player_id: Player ID.

    Returns:
        DataFrame of market lines (finish, matchups, props, …).
    """
    resp = rest_request(f"odds/tournament/{tournament_id}/player/{player_id}")
    markets = resp.get("playerMarkets") or []
    if not markets:
        return pd.DataFrame()

    rows = []
    for m in markets:
        if not isinstance(m, dict):
            continue
        base = {
            "sub_market_name": m.get("subMarketName"),
            "market_display_name": m.get("marketDisplayName"),
            "market_type": m.get("marketType"),
            "layout_type": m.get("layoutType"),
        }
        groups = m.get("oddsDataGroup") or []
        if not groups:
            rows.append(base)
            continue
        for g in groups:
            if not isinstance(g, dict):
                continue
            row = dict(base)
            row["group_title"] = g.get("title")
            # Flatten the first line of odds if present.
            odds = g.get("odds") or g.get("selections") or g.get("data") or []
            if isinstance(odds, list) and odds and isinstance(odds[0], dict):
                first = odds[0]
                row["odds"] = first.get("odds") or first.get("price") or first.get("displayOdds")
                row["label"] = first.get("label") or first.get("name")
            elif isinstance(g.get("odds"), (str, int, float)):
                row["odds"] = g.get("odds")
            rows.append(row)
    if not rows:
        return pd.DataFrame()
    return pd.DataFrame(rows)

pga_tour_api.pga_coverage(tournament_id: str) -> pd.DataFrame

Get broadcast/streaming coverage info.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required

Returns:

Type Description
DataFrame

DataFrame of coverage entries.

Source code in src/pga_tour_api/client.py
def pga_coverage(tournament_id: str) -> pd.DataFrame:
    """Get broadcast/streaming coverage info.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").

    Returns:
        DataFrame of coverage entries.
    """
    data = graphql_request("Coverage", {"tournamentId": tournament_id})
    coverage = _safe_get(data, "coverage")
    if not coverage:
        return pd.DataFrame()

    items = coverage.get("coverageType", [])
    broadcast_types = {
        "BroadcastFullTelecast",
        "BroadcastFeaturedGroup",
        "BroadcastFeaturedHole",
    }
    items = [i for i in items if i.get("__typename") in broadcast_types]
    if not items:
        return pd.DataFrame()

    rows = []
    for item in items:
        rows.append({
            "coverage_type": item.get("__typename"),
            "id": item.get("id"),
            "stream_title": item.get("streamTitle"),
            "round_number": item.get("roundNumber"),
            "start_time": _epoch_ms_to_datetime(item.get("startTime")),
            "end_time": _epoch_ms_to_datetime(item.get("endTime")),
            "live_status": item.get("liveStatus"),
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_weather(tournament_id: str) -> pd.DataFrame

Get hourly and daily weather for a tournament.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID.

required

Returns:

Type Description
DataFrame

DataFrame with a horizon column (hourly / daily).

Source code in src/pga_tour_api/client.py
def pga_weather(tournament_id: str) -> pd.DataFrame:
    """Get hourly and daily weather for a tournament.

    Args:
        tournament_id: Tournament ID.

    Returns:
        DataFrame with a ``horizon`` column (``hourly`` / ``daily``).
    """
    data = graphql_request("Weather", {"tournamentId": tournament_id})
    weather = data.get("weather") or {}
    rows = []
    for horizon in ("hourly", "daily"):
        for item in weather.get(horizon) or []:
            if not isinstance(item, dict):
                continue
            row = {
                "horizon": horizon,
                "title": item.get("title"),
                "condition": item.get("condition"),
                "wind_direction": item.get("windDirection"),
                "wind_speed_mph": item.get("windSpeedMPH"),
                "wind_speed_kph": item.get("windSpeedKPH"),
                "humidity": item.get("humidity"),
                "precipitation": item.get("precipitation"),
            }
            row.update(_weather_temp(item.get("temperature")))
            rows.append(row)
    if not rows:
        return pd.DataFrame()
    df = pd.DataFrame(rows)
    df.attrs["title"] = weather.get("title")
    return df

pga_tour_api.pga_course_stats(tournament_id: str) -> pd.DataFrame

Get per-hole course stats for a tournament.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID.

required

Returns:

Type Description
DataFrame

DataFrame with one row per hole (or summary row) per round.

Source code in src/pga_tour_api/client.py
def pga_course_stats(tournament_id: str) -> pd.DataFrame:
    """Get per-hole course stats for a tournament.

    Args:
        tournament_id: Tournament ID.

    Returns:
        DataFrame with one row per hole (or summary row) per round.
    """
    data = graphql_request("CourseStats", {"tournamentId": tournament_id})
    payload = data.get("courseStats") or {}
    rows = []
    for course in payload.get("courses") or []:
        if not isinstance(course, dict):
            continue
        for rnd in course.get("roundHoleStats") or []:
            if not isinstance(rnd, dict):
                continue
            for hole in rnd.get("holeStats") or []:
                if not isinstance(hole, dict):
                    continue
                rows.append({
                    "course_id": course.get("courseId"),
                    "course_name": course.get("courseName"),
                    "course_code": course.get("courseCode"),
                    "host_course": course.get("hostCourse"),
                    "round_number": rnd.get("roundNum"),
                    "round_header": rnd.get("roundHeader"),
                    "live": rnd.get("live") or hole.get("live"),
                    "row_type": hole.get("rowType") or hole.get("__typename"),
                    "hole_number": hole.get("courseHoleNum"),
                    "par": hole.get("parValue") or hole.get("par"),
                    "yardage": hole.get("yards") or hole.get("yardage"),
                    "scoring_average": hole.get("scoringAverage"),
                    "scoring_average_diff": hole.get("scoringAverageDiff"),
                    "eagles": hole.get("eagles"),
                    "birdies": hole.get("birdies"),
                    "pars": hole.get("pars"),
                    "bogeys": hole.get("bogeys"),
                    "double_bogey": hole.get("doubleBogey"),
                    "rank": hole.get("rank"),
                })
    if not rows:
        return pd.DataFrame()
    return pd.DataFrame(rows)

Statistics & Standings

pga_tour_api.pga_stats(stat_id: str | list[str], year: int | list[int] | None = None, tour: str = 'R', *, event_query: str | None = None) -> pd.DataFrame

Get PGA Tour statistics.

Accepts a single stat ID or a list, and a single year or a list. The upstream StatDetails operation only accepts one (statId, year) pair per call, so multi-stat or multi-year requests loop client-side and concatenate. Each row carries stat_id and year columns so chunks remain distinguishable.

Parameters:

Name Type Description Default
stat_id str | list[str]

Stat ID (e.g., "02675" for SG: Total) or list of stat IDs.

required
year int | list[int] | None

Season year or list of years. Defaults to current season.

None
tour str

Tour code. Defaults to "R".

'R'
event_query str | None

Optional event filter forwarded to the GraphQL StatDetailEventQuery variable (e.g. last-N-events filters on the PGA Tour stats page).

None

Returns:

Type Description
DataFrame

DataFrame with stat_id and year columns followed by player

DataFrame

rankings. For a single-call result, metadata is also available via

DataFrame

df.attrs (stat_title, stat_description, tour_avg,

DataFrame

year, display_season).

Source code in src/pga_tour_api/client.py
def pga_stats(
    stat_id: str | list[str],
    year: int | list[int] | None = None,
    tour: str = "R",
    *,
    event_query: str | None = None,
) -> pd.DataFrame:
    """Get PGA Tour statistics.

    Accepts a single stat ID or a list, and a single year or a list. The
    upstream ``StatDetails`` operation only accepts one ``(statId, year)``
    pair per call, so multi-stat or multi-year requests loop client-side
    and concatenate. Each row carries ``stat_id`` and ``year`` columns so
    chunks remain distinguishable.

    Args:
        stat_id: Stat ID (e.g., "02675" for SG: Total) or list of stat IDs.
        year: Season year or list of years. Defaults to current season.
        tour: Tour code. Defaults to "R".
        event_query: Optional event filter forwarded to the GraphQL
            ``StatDetailEventQuery`` variable (e.g. last-N-events filters
            on the PGA Tour stats page).

    Returns:
        DataFrame with ``stat_id`` and ``year`` columns followed by player
        rankings. For a single-call result, metadata is also available via
        ``df.attrs`` (``stat_title``, ``stat_description``, ``tour_avg``,
        ``year``, ``display_season``).
    """
    _validate_tour(tour)

    stat_ids = _as_list(stat_id)
    if not stat_ids:
        raise ValueError("stat_id must be a non-empty string or list of strings")
    for sid in stat_ids:
        if not isinstance(sid, str) or not sid.strip():
            raise ValueError(f"stat_id entries must be non-empty strings; got {sid!r}")

    years = _as_list(year) if year is not None else [None]
    for y in years:
        if y is not None and not isinstance(y, (int,)):
            raise ValueError(f"year entries must be int or None; got {y!r}")

    single = len(stat_ids) == 1 and len(years) == 1
    frames: list[pd.DataFrame] = []
    for sid, y in product(stat_ids, years):
        frames.append(
            _stats_one(stat_id=sid, year=y, tour=tour, event_query=event_query)
        )

    if single:
        return frames[0]

    non_empty = [f for f in frames if not f.empty]
    if not non_empty:
        return pd.DataFrame()
    return pd.concat(non_empty, ignore_index=True, sort=False)

pga_tour_api.pga_fedex_cup(year: int | None = None, tour: str = 'R', *, event_query: str | None = None) -> pd.DataFrame

Get FedExCup standings.

Parameters:

Name Type Description Default
year int | None

Season year. Defaults to current year.

None
tour str

Tour code. Defaults to "R".

'R'
event_query str | None

Optional event filter forwarded to the GraphQL StatDetailEventQuery variable (e.g. last-N-events filters).

None

Returns:

Type Description
DataFrame

DataFrame with player standings.

Source code in src/pga_tour_api/client.py
def pga_fedex_cup(
    year: int | None = None,
    tour: str = "R",
    *,
    event_query: str | None = None,
) -> pd.DataFrame:
    """Get FedExCup standings.

    Args:
        year: Season year. Defaults to current year.
        tour: Tour code. Defaults to "R".
        event_query: Optional event filter forwarded to the GraphQL
            ``StatDetailEventQuery`` variable (e.g. last-N-events filters).

    Returns:
        DataFrame with player standings.
    """
    _validate_tour(tour)
    if year is None:
        year = datetime.now().year

    variables: dict[str, Any] = {
        "tourCode": tour,
        "id": "02671",
        "year": int(year),
    }
    if event_query is not None:
        variables["eventQuery"] = event_query

    data = graphql_request("TourCupSplit", variables)
    cup = data.get("tourCupSplit")
    if not cup:
        return pd.DataFrame()

    players = cup.get("projectedPlayers") or cup.get("officialPlayers") or []
    player_rows = [
        p for p in players if p.get("__typename") == "TourCupCombinedPlayer"
    ]
    if not player_rows:
        return pd.DataFrame()

    rows = []
    for p in player_rows:
        rows.append({
            "player_id": p.get("id"),
            "first_name": p.get("firstName"),
            "last_name": p.get("lastName"),
            "display_name": p.get("displayName"),
            "country": p.get("country"),
            "country_flag": p.get("countryFlag"),
            "this_week_rank": p.get("thisWeekRank"),
            "previous_week_rank": p.get("previousWeekRank"),
            "projected_rank": _safe_get(p, "rankingData", "projected"),
            "official_rank": _safe_get(p, "rankingData", "official"),
            "projected_points": _safe_get(p, "pointData", "projected"),
            "official_points": _safe_get(p, "pointData", "official"),
            "movement": _safe_get(p, "rankingData", "movement"),
            "movement_amount": _safe_get(p, "rankingData", "movementAmount"),
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_signature_standings(tour: str = 'R') -> pd.DataFrame

Get Signature Event standings.

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'

Returns:

Type Description
DataFrame

DataFrame of official (and interim, if present) standings.

Source code in src/pga_tour_api/client.py
def pga_signature_standings(tour: str = "R") -> pd.DataFrame:
    """Get Signature Event standings.

    Args:
        tour: Tour code. Defaults to ``"R"``.

    Returns:
        DataFrame of official (and interim, if present) standings.
    """
    _validate_tour(tour)
    data = graphql_request("SignatureStandings", {"tourCode": tour})
    payload = data.get("signatureStandings") or {}
    rows = []
    for table_name in ("official", "interim"):
        table = payload.get(table_name) or {}
        for p in table.get("players") or []:
            if not isinstance(p, dict) or p.get("__typename") != "SignaturePlayer":
                continue
            rows.append({
                "table": table_name,
                "player_id": p.get("playerId"),
                "display_name": p.get("displayName"),
                "short_name": p.get("shortName"),
                "country": p.get("countryName"),
                "country_flag": p.get("countryFlag"),
                "started": p.get("started"),
                "projected": p.get("projected"),
                "projected_points": p.get("projectedPoints"),
                "movement_amount": p.get("movementAmount"),
                "movement_direction": p.get("movementDirection"),
            })
    if not rows:
        return pd.DataFrame()
    df = pd.DataFrame(rows)
    df.attrs["info_title"] = payload.get("infoTitle")
    df.attrs["tournament_id"] = payload.get("tournamentID")
    return df

pga_tour_api.pga_priority_rankings(tour: str = 'R', year: int | None = None) -> pd.DataFrame

Get priority / exemption rankings.

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'
year int | None

Season year. Defaults to the current season.

None

Returns:

Type Description
DataFrame

DataFrame with one row per (category, player).

Source code in src/pga_tour_api/client.py
def pga_priority_rankings(
    tour: str = "R",
    year: int | None = None,
) -> pd.DataFrame:
    """Get priority / exemption rankings.

    Args:
        tour: Tour code. Defaults to ``"R"``.
        year: Season year. Defaults to the current season.

    Returns:
        DataFrame with one row per (category, player).
    """
    _validate_tour(tour)
    variables: dict[str, Any] = {"tourCode": tour}
    if year is not None:
        variables["year"] = int(year)

    data = graphql_request("PriorityRankings", variables)
    payload = data.get("priorityRankings") or {}
    rows = []
    for cat in payload.get("categories") or []:
        if not isinstance(cat, dict):
            continue
        category = cat.get("displayName")
        detail = cat.get("detail")
        for p in cat.get("players") or []:
            if not isinstance(p, dict):
                continue
            rows.append({
                "category": category,
                "detail": detail,
                "player_id": p.get("playerId"),
                "display_name": p.get("displayName"),
            })
    if not rows:
        return pd.DataFrame()
    df = pd.DataFrame(rows)
    df.attrs["year"] = payload.get("year")
    df.attrs["display_year"] = payload.get("displayYear")
    df.attrs["through"] = payload.get("throughText")
    return df

pga_tour_api.pga_scorecard_comparison(tournament_id: str, player_ids: list[str], category: str = 'SCORING') -> pd.DataFrame

Get scorecard stat comparison between players.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required
player_ids list[str]

List of player IDs to compare.

required
category str

Comparison category (e.g., "SCORING", "DRIVING").

'SCORING'

Returns:

Type Description
DataFrame

DataFrame of comparison category pills.

Source code in src/pga_tour_api/client.py
def pga_scorecard_comparison(
    tournament_id: str,
    player_ids: list[str],
    category: str = "SCORING",
) -> pd.DataFrame:
    """Get scorecard stat comparison between players.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").
        player_ids: List of player IDs to compare.
        category: Comparison category (e.g., "SCORING", "DRIVING").

    Returns:
        DataFrame of comparison category pills.
    """
    data = graphql_request(
        "ScorecardStatsComparisonCategories",
        {
            "tournamentId": tournament_id,
            "playerIds": player_ids,
            "category": category,
        },
    )
    comparison = _safe_get(data, "scorecardStatsComparison")
    if not comparison:
        return pd.DataFrame()

    pills = comparison.get("categoryPills", [])
    if not pills:
        return pd.DataFrame([{
            "tournament_id": comparison.get("tournamentId"),
            "category": comparison.get("category"),
        }])

    df = pd.DataFrame(pills)
    df.columns = ["display_text", "category"]
    df.attrs["tournament_id"] = comparison.get("tournamentId")
    df.attrs["selected_category"] = comparison.get("category")
    return df

pga_tour_api.pga_course_stats_overview(tour: str = 'R', year: int | None = None) -> pd.DataFrame

Get the season course-stats hub.

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'
year int | None

Season year. Defaults to the current season.

None

Returns:

Type Description
DataFrame

Long DataFrame of category items and their detail labels.

Source code in src/pga_tour_api/client.py
def pga_course_stats_overview(
    tour: str = "R",
    year: int | None = None,
) -> pd.DataFrame:
    """Get the season course-stats hub.

    Args:
        tour: Tour code. Defaults to ``"R"``.
        year: Season year. Defaults to the current season.

    Returns:
        Long DataFrame of category items and their detail labels.
    """
    _validate_tour(tour)
    variables: dict[str, Any] = {"tourCode": tour}
    if year is not None:
        variables["year"] = int(year)

    data = graphql_request("CourseStatsOverview", variables)
    payload = data.get("courseStatsOverview") or {}
    rows = []
    for cat in payload.get("categories") or []:
        if not isinstance(cat, dict):
            continue
        for item in cat.get("items") or []:
            if not isinstance(item, dict):
                continue
            details = item.get("details") or []
            if not details:
                rows.append({
                    "category": cat.get("header"),
                    "display_name": item.get("displayName"),
                    "rank": item.get("rank"),
                    "label": None,
                    "value": None,
                })
                continue
            for detail in details:
                if not isinstance(detail, dict):
                    continue
                rows.append({
                    "category": cat.get("header"),
                    "display_name": item.get("displayName"),
                    "rank": item.get("rank"),
                    "label": detail.get("label"),
                    "value": detail.get("value"),
                })
    if not rows:
        return pd.DataFrame()
    df = pd.DataFrame(rows)
    df.attrs["tour"] = payload.get("tourCode")
    df.attrs["year"] = payload.get("year")
    return df

Players & Tournaments

pga_tour_api.pga_players(tour: str = 'R') -> pd.DataFrame

Get PGA Tour player directory.

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'

Returns:

Type Description
DataFrame

DataFrame with one row per player.

Source code in src/pga_tour_api/client.py
def pga_players(tour: str = "R") -> pd.DataFrame:
    """Get PGA Tour player directory.

    Args:
        tour: Tour code. Defaults to "R".

    Returns:
        DataFrame with one row per player.
    """
    _validate_tour(tour)
    resp = rest_request(f"player/list/{tour}")
    players = resp.get("players", [])
    if not players:
        return pd.DataFrame()

    rows = []
    for p in players:
        rows.append({
            "player_id": p.get("id"),
            "tour_code": p.get("tourCode"),
            "is_primary": p.get("isPrimary"),
            "is_active": p.get("isActive"),
            "first_name": p.get("firstName"),
            "last_name": p.get("lastName"),
            "display_name": p.get("displayName"),
            "short_name": p.get("shortName"),
            "country": p.get("country"),
            "country_flag": p.get("countryFlag"),
            "age": _safe_get(p, "playerBio", "age"),
            "primary_tour": p.get("primaryTour"),
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_tournaments(ids: str | list[str]) -> pd.DataFrame

Get tournament metadata.

Parameters:

Name Type Description Default
ids str | list[str]

One or more tournament IDs (e.g., "R2026475").

required

Returns:

Type Description
DataFrame

DataFrame with one row per tournament.

Source code in src/pga_tour_api/client.py
def pga_tournaments(ids: str | list[str]) -> pd.DataFrame:
    """Get tournament metadata.

    Args:
        ids: One or more tournament IDs (e.g., "R2026475").

    Returns:
        DataFrame with one row per tournament.
    """
    if isinstance(ids, str):
        ids = [ids]

    data = graphql_request("Tournaments", {"ids": ids})
    tournaments = data.get("tournaments", [])
    if not tournaments:
        return pd.DataFrame()

    rows = []
    for t in tournaments:
        weather = t.get("weather") or {}
        rows.append({
            "id": t.get("id"),
            "tournament_name": t.get("tournamentName"),
            "tournament_status": t.get("tournamentStatus"),
            "display_date": t.get("displayDate"),
            "season_year": t.get("seasonYear"),
            "country": t.get("country"),
            "state": t.get("state"),
            "city": t.get("city"),
            "timezone": t.get("timezone"),
            "format_type": t.get("formatType"),
            "current_round": t.get("currentRound"),
            "round_status": t.get("roundStatus"),
            "round_display": t.get("roundDisplay"),
            "round_status_display": t.get("roundStatusDisplay"),
            "scored_level": t.get("scoredLevel"),
            "tournament_site_url": t.get("tournamentSiteURL"),
            "beauty_image": t.get("beautyImage"),
            "headshot_base_url": t.get("headshotBaseUrl"),
            "weather_temp_f": weather.get("tempF"),
            "weather_temp_c": weather.get("tempC"),
            "weather_condition": weather.get("condition"),
            "weather_wind_mph": weather.get("windSpeedMPH"),
            "weather_humidity": weather.get("humidity"),
            "courses": [
                {
                    "id": c.get("id"),
                    "course_name": c.get("courseName"),
                    "course_code": c.get("courseCode"),
                    "host_course": c.get("hostCourse"),
                }
                for c in (t.get("courses") or [])
            ],
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_schedule(year: int | None = None, tour: str = 'R') -> pd.DataFrame

Get season schedule.

Parameters:

Name Type Description Default
year int | None

Season year. Defaults to current year.

None
tour str

Tour code. Defaults to "R".

'R'

Returns:

Type Description
DataFrame

DataFrame with one row per tournament including dates, purse,

DataFrame

course, champion, and FedExCup points.

Source code in src/pga_tour_api/client.py
def pga_schedule(
    year: int | None = None,
    tour: str = "R",
) -> pd.DataFrame:
    """Get season schedule.

    Args:
        year: Season year. Defaults to current year.
        tour: Tour code. Defaults to "R".

    Returns:
        DataFrame with one row per tournament including dates, purse,
        course, champion, and FedExCup points.
    """
    _validate_tour(tour)
    if year is None:
        year = datetime.now().year

    resp = rest_request(f"schedule/{tour}/{year}")
    tournaments = resp.get("tournaments", [])
    if not tournaments:
        return pd.DataFrame()

    rows = []
    for t in tournaments:
        champions = t.get("champions") or []
        champion_name = champions[0].get("displayName") if champions else None
        course = t.get("courseData") or {}
        standings = t.get("standings") or {}

        rows.append({
            "tournament_id": t.get("tournamentId"),
            "tournament_name": t.get("name"),
            "year": t.get("year"),
            "month": t.get("month"),
            "display_date": t.get("displayDate"),
            "status": t.get("status"),
            "purse": t.get("purse"),
            "fedex_cup_points": standings.get("value"),
            "champion": champion_name,
            "champion_earnings": t.get("championEarnings"),
            "course_name": course.get("name"),
            "city": course.get("city"),
            "state": course.get("stateCode"),
            "country": course.get("country"),
            "tournament_site_url": t.get("tournamentSiteUrl"),
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_tournament_overview(tournament_id: str) -> dict

Get tournament overview tiles and champions.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID.

required

Returns:

Type Description
dict

Dict with overview (DataFrame of tiles), defending_champion

dict

(dict or None), past_champions (DataFrame), plus URL

dict

scalars (tickets_url, tourcast_url, share_url,

dict

event_guide_url).

Source code in src/pga_tour_api/client.py
def pga_tournament_overview(tournament_id: str) -> dict:
    """Get tournament overview tiles and champions.

    Args:
        tournament_id: Tournament ID.

    Returns:
        Dict with ``overview`` (DataFrame of tiles), ``defending_champion``
        (dict or ``None``), ``past_champions`` (DataFrame), plus URL
        scalars (``tickets_url``, ``tourcast_url``, ``share_url``,
        ``event_guide_url``).
    """
    data = graphql_request("TournamentOverview", {"tournamentId": tournament_id})
    ov = data.get("tournamentOverview") or {}

    overview = pd.DataFrame([
        {
            "label": item.get("label"),
            "value": item.get("value"),
            "detail": item.get("detail"),
            "secondary_detail": item.get("secondaryDetail"),
        }
        for item in (ov.get("overview") or [])
        if isinstance(item, dict)
    ])

    def _champ(c: dict | None) -> dict | None:
        if not isinstance(c, dict) or not c:
            return None
        return {
            "player_id": c.get("playerId"),
            "display_name": c.get("displayName"),
            "year": c.get("year"),
            "display_season": c.get("displaySeason"),
            "score": c.get("score"),
            "total": c.get("total"),
            "title": c.get("title"),
            "country_code": c.get("countryCode"),
        }

    past = [
        _champ(c)
        for c in (ov.get("pastChampions") or [])
        if isinstance(c, dict)
    ]
    past_df = pd.DataFrame([c for c in past if c]) if past else pd.DataFrame()

    return {
        "overview": overview,
        "defending_champion": _champ(ov.get("defendingChampion")),
        "past_champions": past_df,
        "tickets_url": ov.get("ticketsURL"),
        "tourcast_url": ov.get("tourcastURL") or ov.get("tourcastURLWeb"),
        "share_url": ov.get("shareURL"),
        "event_guide_url": ov.get("eventGuideURL"),
    }

pga_tour_api.pga_tournament_past_results(tournament_id: str, year: int | None = None) -> pd.DataFrame

Get historical finishes for a tournament.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (perm id, e.g. "R2026027").

required
year int | None

Season year. Defaults to the most recent year the API returns.

None

Returns:

Type Description
DataFrame

DataFrame with one row per player.

Source code in src/pga_tour_api/client.py
def pga_tournament_past_results(
    tournament_id: str,
    year: int | None = None,
) -> pd.DataFrame:
    """Get historical finishes for a tournament.

    Args:
        tournament_id: Tournament ID (perm id, e.g. ``"R2026027"``).
        year: Season year. Defaults to the most recent year the API
            returns.

    Returns:
        DataFrame with one row per player.
    """
    variables: dict[str, Any] = {"tournamentPastResultsId": tournament_id}
    if year is not None:
        variables["year"] = int(year)

    data = graphql_request("TournamentPastResults", variables)
    payload = data.get("tournamentPastResults") or {}
    players = payload.get("players") or []
    if not players:
        return pd.DataFrame()

    rows = []
    for p in players:
        if not isinstance(p, dict):
            continue
        player = p.get("player") or {}
        rounds = p.get("rounds") or []
        row = {
            "player_id": player.get("id") or p.get("id"),
            "first_name": player.get("firstName"),
            "last_name": player.get("lastName"),
            "display_name": player.get("displayName"),
            "short_name": player.get("shortName"),
            "country": player.get("country"),
            "country_flag": player.get("countryFlag"),
            "amateur": player.get("amateur"),
            "position": p.get("position"),
            "total": p.get("total"),
            "to_par": p.get("parRelativeScore"),
        }
        extra = p.get("additionalData") or []
        for i, val in enumerate(extra):
            row[f"additional_{i}"] = val
        for i, rnd in enumerate(rounds, 1):
            if isinstance(rnd, dict):
                row[f"round_{i}"] = rnd.get("score")
                row[f"round_{i}_to_par"] = rnd.get("parRelativeScore")
            else:
                row[f"round_{i}"] = rnd
        rows.append(row)

    if not rows:
        return pd.DataFrame()
    df = pd.DataFrame(rows)
    df.attrs["available_seasons"] = payload.get("availableSeasons")
    return df

Player Profiles

pga_tour_api.pga_player_profile(player_id: str) -> dict

Get player profile overview.

Parameters:

Name Type Description Default
player_id str

Player ID (e.g., "52955" for Ludvig Aberg).

required

Returns:

Type Description
dict

Dict with flat bio scalars (player_id, first_name,

dict

last_name, country, country_code, born, age,

dict

birthplace, college, turned_pro) plus two DataFrames:

dict
  • highlights: career-highlight tiles (title, value, subtitle)
dict
  • overview: overview-stats grid (section, subtitle, title, value)
Source code in src/pga_tour_api/client.py
def pga_player_profile(player_id: str) -> dict:
    """Get player profile overview.

    Args:
        player_id: Player ID (e.g., "52955" for Ludvig Aberg).

    Returns:
        Dict with flat bio scalars (``player_id``, ``first_name``,
        ``last_name``, ``country``, ``country_code``, ``born``, ``age``,
        ``birthplace``, ``college``, ``turned_pro``) plus two DataFrames:

        - ``highlights``: career-highlight tiles (title, value, subtitle)
        - ``overview``: overview-stats grid (section, subtitle, title, value)
    """
    resp = rest_request(f"player/profiles/{player_id}")
    summary = _safe_get(resp, "summaryData", "summaryData") or {}

    highlights = pd.DataFrame([
        {
            "title": h.get("title"),
            "value": h.get("data"),
            "subtitle": h.get("subTitle"),
        }
        for h in summary.get("careerHighlights", [])
    ])

    overview_rows = []
    for section in resp.get("overview", []):
        if section.get("type") == "OVERVIEW_STATS":
            for item in section.get("items", []):
                for el in item.get("elements", []):
                    overview_rows.append({
                        "section": item.get("title"),
                        "subtitle": item.get("subtitle"),
                        "title": el.get("title"),
                        "value": el.get("data"),
                    })
    overview = pd.DataFrame(overview_rows) if overview_rows else pd.DataFrame()

    return {
        "player_id": resp.get("playerId"),
        "first_name": summary.get("firstName"),
        "last_name": summary.get("lastName"),
        "country": summary.get("country"),
        "country_code": summary.get("countryCode"),
        "born": summary.get("born"),
        "age": summary.get("age"),
        "birthplace": summary.get("birthplace"),
        "college": summary.get("college"),
        "turned_pro": summary.get("turnedPro"),
        "highlights": highlights,
        "overview": overview,
    }

pga_tour_api.pga_player_career(player_id: str) -> pd.DataFrame

Get player career data.

Returns career achievements including starts, cuts, wins, finish distribution, and earnings.

Parameters:

Name Type Description Default
player_id str

Player ID.

required

Returns:

Type Description
DataFrame

DataFrame of career statistics.

Source code in src/pga_tour_api/client.py
def pga_player_career(player_id: str) -> pd.DataFrame:
    """Get player career data.

    Returns career achievements including starts, cuts, wins,
    finish distribution, and earnings.

    Args:
        player_id: Player ID.

    Returns:
        DataFrame of career statistics.
    """
    resp = rest_request(f"player/profiles/{player_id}/career")
    career_list = resp.get("career", [])
    if not career_list:
        return pd.DataFrame()

    rows = []
    for tour_data in career_list:
        tour_code = tour_data.get("tourCode")
        tour_name = tour_data.get("tourName")
        for section in tour_data.get("careerData", []):
            section_title = section.get("title")
            for widget in section.get("stats", []):
                widget_title = widget.get("title")
                for item in widget.get("data", []):
                    rows.append({
                        "tour_code": tour_code,
                        "tour_name": tour_name,
                        "section": section_title,
                        "widget": widget_title,
                        "label": item.get("label"),
                        "value": item.get("data"),
                    })

    return pd.DataFrame(rows) if rows else pd.DataFrame()

pga_tour_api.pga_player_results(player_id: str, season: int | list[int] | None = None) -> pd.DataFrame

Get player tournament results.

The upstream REST endpoint returns one season per call. Pass season to request specific years, or omit it to loop every season listed in resultPills (the 0.2.0 "every season" contract). Each row carries a season column. Dynamic header labels are coerced to snake_case and deduplicated so column names never collide.

Parameters:

Name Type Description Default
player_id str

Player ID.

required
season int | list[int] | None

Season year, list of years, or None for every season the API advertises for this player.

None

Returns:

Type Description
DataFrame

DataFrame with one row per tournament across the requested

DataFrame

seasons.

Source code in src/pga_tour_api/client.py
def pga_player_results(
    player_id: str,
    season: int | list[int] | None = None,
) -> pd.DataFrame:
    """Get player tournament results.

    The upstream REST endpoint returns one season per call. Pass
    ``season`` to request specific years, or omit it to loop every
    season listed in ``resultPills`` (the 0.2.0 "every season"
    contract). Each row carries a ``season`` column. Dynamic header
    labels are coerced to snake_case and deduplicated so column names
    never collide.

    Args:
        player_id: Player ID.
        season: Season year, list of years, or ``None`` for every
            season the API advertises for this player.

    Returns:
        DataFrame with one row per tournament across the requested
        seasons.
    """
    if season is not None:
        years = _as_list(season)
        if not years:
            raise ValueError("season must be a non-empty int or list of ints")
        for y in years:
            if not isinstance(y, int):
                raise ValueError(f"season entries must be int; got {y!r}")
        frames: list[pd.DataFrame] = []
        for y in years:
            resp = rest_request(f"player/profiles/{player_id}/results?season={y}")
            frames.extend(_frames_from_results_resp(resp, default_season=y))
        if not frames:
            return pd.DataFrame()
        return pd.concat(frames, ignore_index=True, sort=False)

    resp = rest_request(f"player/profiles/{player_id}/results")
    results_list = resp.get("resultsData") or []
    pill_years = _season_years_from_pills(resp)

    # Legacy / fixture payloads already contain every season as
    # separate resultsData blocks. Parse them in place.
    if len(results_list) > 1 or not pill_years:
        frames = _frames_from_results_resp(resp)
        if not frames:
            return pd.DataFrame()
        return pd.concat(frames, ignore_index=True, sort=False)

    frames = []
    for y in pill_years:
        season_resp = rest_request(
            f"player/profiles/{player_id}/results?season={y}"
        )
        frames.extend(_frames_from_results_resp(season_resp, default_season=y))
    if not frames:
        return pd.DataFrame()
    return pd.concat(frames, ignore_index=True, sort=False)

pga_tour_api.pga_player_stats(player_id: str) -> pd.DataFrame

Get player stats profile.

Returns a player's full statistical profile with ranks and values for 130+ stats in a single call.

Parameters:

Name Type Description Default
player_id str

Player ID.

required

Returns:

Type Description
DataFrame

DataFrame with one row per stat.

Source code in src/pga_tour_api/client.py
def pga_player_stats(player_id: str) -> pd.DataFrame:
    """Get player stats profile.

    Returns a player's full statistical profile with ranks and values
    for 130+ stats in a single call.

    Args:
        player_id: Player ID.

    Returns:
        DataFrame with one row per stat.
    """
    resp = rest_request(f"player/profiles/{player_id}/stats")
    stats = resp.get("stats", [])
    if not stats:
        return pd.DataFrame()

    rows = []
    for s in stats:
        cats = s.get("category") or []
        rows.append({
            "stat_id": s.get("statId"),
            "title": s.get("title"),
            "rank": s.get("rank"),
            "value": s.get("value"),
            "category": ", ".join(cats) if cats else None,
            "above_or_below": s.get("aboveOrBelow"),
            "field_average": s.get("fieldAverage"),
            "supporting_stat_desc": _safe_get(s, "supportingStat", "description"),
            "supporting_stat_value": _safe_get(s, "supportingStat", "value"),
            "supporting_value_desc": _safe_get(s, "supportingValue", "description"),
            "supporting_value_value": _safe_get(s, "supportingValue", "value"),
        })

    df = pd.DataFrame(rows)
    if not df.empty:
        df["rank"] = pd.to_numeric(df["rank"], errors="coerce").astype("Int64")
    return df

pga_tour_api.pga_player_bio(player_id: str) -> dict

Get player bio.

Returns biographical text, amateur highlights, and widget data.

Parameters:

Name Type Description Default
player_id str

Player ID.

required

Returns:

Type Description
dict

Dict with text (list of paragraphs), amateur_highlights

dict

(list of strings), and widgets DataFrame.

Source code in src/pga_tour_api/client.py
def pga_player_bio(player_id: str) -> dict:
    """Get player bio.

    Returns biographical text, amateur highlights, and widget data.

    Args:
        player_id: Player ID.

    Returns:
        Dict with ``text`` (list of paragraphs), ``amateur_highlights``
        (list of strings), and ``widgets`` DataFrame.
    """
    resp = rest_request(f"player/profiles/{player_id}/bio")
    bio = resp.get("bio", {})
    widgets = resp.get("widgets", [])

    bio_text = [e for e in bio.get("elements", []) if isinstance(e, str)]
    amateur = [e for e in bio.get("amateurHighlights", []) if isinstance(e, str)]

    widget_rows = []
    for w in widgets:
        for item in w.get("items", []):
            widget_rows.append({
                "widget_type": w.get("type"),
                "widget_title": w.get("title"),
                "label": item.get("label"),
                "value": item.get("value"),
            })

    return {
        "text": bio_text,
        "amateur_highlights": amateur,
        "widgets": pd.DataFrame(widget_rows) if widget_rows else pd.DataFrame(),
    }

pga_tour_api.pga_player_tournament_status(player_id: str) -> pd.DataFrame

Get player tournament status.

Returns the player's status in the current tournament (if playing). Returns an empty DataFrame when the API returns no status, or when every scalar field on the status object is null — callers can rely on len(df) > 0 to detect "player is in a tournament right now."

Parameters:

Name Type Description Default
player_id str

Player ID.

required

Returns:

Type Description
DataFrame

DataFrame with one row, or empty if not currently in a tournament.

Source code in src/pga_tour_api/client.py
def pga_player_tournament_status(player_id: str) -> pd.DataFrame:
    """Get player tournament status.

    Returns the player's status in the current tournament (if playing).
    Returns an empty DataFrame when the API returns no status, or when
    every scalar field on the status object is null — callers can rely
    on ``len(df) > 0`` to detect "player is in a tournament right now."

    Args:
        player_id: Player ID.

    Returns:
        DataFrame with one row, or empty if not currently in a tournament.
    """
    data = graphql_request(
        "getPlayerTournamentStatus",
        {"playerId": player_id},
    )
    status = data.get("playerTournamentStatus")
    if not isinstance(status, dict):
        return pd.DataFrame()

    row = {
        "player_id": status.get("playerId"),
        "tournament_id": status.get("tournamentId"),
        "tournament_name": status.get("tournamentName"),
        "position": status.get("position"),
        "thru": status.get("thru"),
        "score": status.get("score"),
        "total": status.get("total"),
        "round_status_display": status.get("roundStatusDisplay"),
        "round_status_color": status.get("roundStatusColor"),
        "round_display": status.get("roundDisplay"),
        "round_status": status.get("roundStatus"),
        "tee_time": status.get("teeTime"),
        "display_mode": status.get("displayMode"),
    }
    # Empty-status contract: if every scalar field is null, return zero rows.
    if all(v is None for v in row.values()):
        return pd.DataFrame()
    return pd.DataFrame([row])

Content

pga_tour_api.pga_news(tour: str = 'R', franchises: list[str] | None = None, player_ids: list[str] | None = None, limit: int = 20, offset: int = 0) -> pd.DataFrame

Get news articles.

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'
franchises list[str] | None

Filter by franchise categories.

None
player_ids list[str] | None

Filter by player IDs.

None
limit int

Max articles. Defaults to 20.

20
offset int

Pagination offset.

0

Returns:

Type Description
DataFrame

DataFrame with one row per article.

Source code in src/pga_tour_api/client.py
def pga_news(
    tour: str = "R",
    franchises: list[str] | None = None,
    player_ids: list[str] | None = None,
    limit: int = 20,
    offset: int = 0,
) -> pd.DataFrame:
    """Get news articles.

    Args:
        tour: Tour code. Defaults to "R".
        franchises: Filter by franchise categories.
        player_ids: Filter by player IDs.
        limit: Max articles. Defaults to 20.
        offset: Pagination offset.

    Returns:
        DataFrame with one row per article.
    """
    _validate_tour(tour)
    variables: dict[str, Any] = {
        "tour": tour,
        "limit": limit,
        "offset": offset,
    }
    if franchises:
        variables["franchises"] = franchises
    if player_ids:
        variables["playerIds"] = player_ids

    data = graphql_request("NewsArticles", variables)
    articles = _safe_get(data, "newsArticles", "articles") or []
    if not articles:
        return pd.DataFrame()

    rows = []
    for a in articles:
        author = a.get("author") or {}
        rows.append({
            "id": a.get("id"),
            "headline": a.get("headline"),
            "teaser_headline": a.get("teaserHeadline"),
            "teaser_content": a.get("teaserContent"),
            "url": a.get("url"),
            "share_url": a.get("shareURL"),
            "publish_date": _epoch_ms_to_datetime(a.get("publishDate")),
            "update_date": _epoch_ms_to_datetime(a.get("updateDate")),
            "franchise": a.get("franchise"),
            "franchise_display_name": a.get("franchiseDisplayName"),
            "article_image": a.get("articleImage"),
            "author_first": author.get("firstName"),
            "author_last": author.get("lastName"),
            "is_live": a.get("isLive"),
            "ai_generated": a.get("aiGenerated"),
            "article_form_type": a.get("articleFormType"),
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_news_franchises(tour: str = 'R') -> pd.DataFrame

Get news franchise/category list.

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'

Returns:

Type Description
DataFrame

DataFrame with franchise and label columns.

Source code in src/pga_tour_api/client.py
def pga_news_franchises(tour: str = "R") -> pd.DataFrame:
    """Get news franchise/category list.

    Args:
        tour: Tour code. Defaults to "R".

    Returns:
        DataFrame with franchise and label columns.
    """
    data = graphql_request(
        "NewsFranchises",
        {"tourCode": tour, "allFranchises": False},
    )
    franchises = data.get("newsFranchises", [])
    if not franchises:
        return pd.DataFrame()

    return pd.DataFrame([
        {
            "franchise": f.get("franchise"),
            "franchise_label": f.get("franchiseLabel"),
        }
        for f in franchises
    ])

pga_tour_api.pga_videos(player_ids: list[str] | None = None, tournament_id: str | None = None, tour: str = 'R', season: str | None = None, franchises: list[str] | None = None, limit: int = 18, offset: int = 0) -> pd.DataFrame

Get player video highlights.

Parameters:

Name Type Description Default
player_ids list[str] | None

Player IDs to filter by.

None
tournament_id str | None

Tournament ID (numeric part only, e.g., "475").

None
tour str

Tour code. Defaults to "R".

'R'
season str | None

Season year as string.

None
franchises list[str] | None

Franchise filters.

None
limit int

Max videos. Defaults to 18.

18
offset int

Pagination offset.

0

Returns:

Type Description
DataFrame

DataFrame of videos.

Source code in src/pga_tour_api/client.py
def pga_videos(
    player_ids: list[str] | None = None,
    tournament_id: str | None = None,
    tour: str = "R",
    season: str | None = None,
    franchises: list[str] | None = None,
    limit: int = 18,
    offset: int = 0,
) -> pd.DataFrame:
    """Get player video highlights.

    Args:
        player_ids: Player IDs to filter by.
        tournament_id: Tournament ID (numeric part only, e.g., "475").
        tour: Tour code. Defaults to "R".
        season: Season year as string.
        franchises: Franchise filters.
        limit: Max videos. Defaults to 18.
        offset: Pagination offset.

    Returns:
        DataFrame of videos.
    """
    variables: dict[str, Any] = {
        "tourCode": tour,
        "limit": limit,
        "offset": offset,
    }
    if player_ids:
        variables["playerIds"] = player_ids
    if tournament_id:
        variables["tournamentId"] = tournament_id
    if season:
        variables["season"] = season
    if franchises:
        variables["franchises"] = franchises

    data = graphql_request("Videos", variables)
    videos = data.get("videos", [])
    if not videos:
        return pd.DataFrame()

    rows = []
    for v in videos:
        rows.append({
            "id": v.get("id"),
            "title": v.get("title"),
            "description": v.get("description"),
            "duration_secs": v.get("duration"),
            "category": v.get("category"),
            "category_display_name": v.get("categoryDisplayName"),
            "franchise": v.get("franchise"),
            "franchise_display_name": v.get("franchiseDisplayName"),
            "hole_number": v.get("holeNumber"),
            "round_number": v.get("roundNumber"),
            "shot_number": v.get("shotNumber"),
            "share_url": v.get("shareUrl"),
            "thumbnail": v.get("thumbnail"),
            "pub_date": _epoch_ms_to_datetime(v.get("pubdate")),
            "tournament_id": v.get("tournamentId"),
            "tour_code": v.get("tourCode"),
            "year": v.get("year"),
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_tourcast_videos(tournament_id: str, player_id: str, round: int, *, hole: int | None = None, shot: int | None = None) -> pd.DataFrame

Get shot-by-shot video clips for a player round.

Parameters:

Name Type Description Default
tournament_id str

Tournament ID (e.g., "R2026475").

required
player_id str

Player ID.

required
round int

Round number.

required
hole int | None

Specific hole number.

None
shot int | None

Specific shot number.

None

Returns:

Type Description
DataFrame

DataFrame of video clips.

Source code in src/pga_tour_api/client.py
def pga_tourcast_videos(
    tournament_id: str,
    player_id: str,
    round: int,
    *,
    hole: int | None = None,
    shot: int | None = None,
) -> pd.DataFrame:
    """Get shot-by-shot video clips for a player round.

    Args:
        tournament_id: Tournament ID (e.g., "R2026475").
        player_id: Player ID.
        round: Round number.
        hole: Specific hole number.
        shot: Specific shot number.

    Returns:
        DataFrame of video clips.
    """
    variables: dict[str, Any] = {
        "tournamentId": tournament_id,
        "playerId": player_id,
        "round": int(round),
    }
    if hole is not None:
        variables["hole"] = int(hole)
    if shot is not None:
        variables["shot"] = int(shot)

    data = graphql_request("TourcastVideos", variables)
    videos = data.get("tourcastVideos", [])
    if not videos:
        return pd.DataFrame()

    rows = []
    for v in videos:
        rows.append({
            "id": v.get("id"),
            "title": v.get("title"),
            "description": v.get("description"),
            "duration_secs": v.get("duration"),
            "hole_number": v.get("holeNumber"),
            "round_number": v.get("roundNumber"),
            "shot_number": v.get("shotNumber"),
            "share_url": v.get("shareUrl"),
            "thumbnail": v.get("thumbnail"),
            "starts_at": _epoch_ms_to_datetime(v.get("startsAt")),
            "ends_at": _epoch_ms_to_datetime(v.get("endsAt")),
            "tournament_id": v.get("tournamentId"),
            "tour_code": v.get("tourCode"),
        })

    return pd.DataFrame(rows)

pga_tour_api.pga_content(path: str) -> Any

Fetch a CMS content fragment from the GraphQL GenericContentCompressed op.

The shape of the returned object varies by path — it is whatever the CMS publishes for that URL. Returned as the raw parsed JSON, not a DataFrame, since the schema isn't stable across paths.

Parameters:

Name Type Description Default
path str

CMS path (e.g. a tournament landing-page slug).

required

Returns:

Type Description
Any

Parsed JSON from the decompressed payload, or None if the

Any

operation returns no payload.

Source code in src/pga_tour_api/client.py
def pga_content(path: str) -> Any:
    """Fetch a CMS content fragment from the GraphQL ``GenericContentCompressed`` op.

    The shape of the returned object varies by ``path`` — it is whatever
    the CMS publishes for that URL. Returned as the raw parsed JSON,
    not a DataFrame, since the schema isn't stable across paths.

    Args:
        path: CMS path (e.g. a tournament landing-page slug).

    Returns:
        Parsed JSON from the decompressed payload, or ``None`` if the
        operation returns no payload.
    """
    data = graphql_request("GenericContentCompressed", {"path": path})
    payload = _safe_get(data, "genericContentCompressed", "payload")
    if not payload:
        return None
    return decompress_payload(payload)

pga_tour_api.pga_odds_interactivity() -> Any

Fetch the odds-interactivity widget configuration (REST).

Returns the raw parsed JSON — schema is whatever the widget needs and isn't worth coercing into a DataFrame.

Source code in src/pga_tour_api/client.py
def pga_odds_interactivity() -> Any:
    """Fetch the odds-interactivity widget configuration (REST).

    Returns the raw parsed JSON — schema is whatever the widget needs
    and isn't worth coercing into a DataFrame.
    """
    return rest_request("odds/interactivity")

pga_tour_api.pga_speed_rounds(tour: str = 'R') -> Any

Fetch the speed-rounds video index for a tour (REST).

Parameters:

Name Type Description Default
tour str

Tour code. Defaults to "R".

'R'

Returns:

Type Description
Any

Raw parsed JSON from /content/watch/speedRounds/{tour}.

Source code in src/pga_tour_api/client.py
def pga_speed_rounds(tour: str = "R") -> Any:
    """Fetch the speed-rounds video index for a tour (REST).

    Args:
        tour: Tour code. Defaults to "R".

    Returns:
        Raw parsed JSON from ``/content/watch/speedRounds/{tour}``.
    """
    _validate_tour(tour)
    return rest_request(f"content/watch/speedRounds/{tour}")

Errors

pga_tour_api.PgaTourError

Bases: RuntimeError

Raised when a PGA Tour API call fails in a way the caller may want to handle.

Source code in src/pga_tour_api/_api.py
class PgaTourError(RuntimeError):
    """Raised when a PGA Tour API call fails in a way the caller may want to handle."""

Records and detailed performance

Added on 2026-09-24: four functions and four GraphQL operations. These are unofficial upstream data, not independently validated records.

import pga_tour_api as pga

stats = pga.pga_scorecard_stats("R2026030", "59095", round="-1")
courses = pga.pga_course_stats_details(year=2026)
holes = pga.pga_course_stats_details("TOUGHEST_HOLES", year=2026, round="ONE")
catalog = pga.pga_record_catalog()
records = pga.pga_all_time_records("2-1-11")

Scorecard sections are performance, scoring and strokesGained; the same stat can appear in more than one section. Round "-1" is the aggregate. Course round selectors are ALL, ONE, TWO, THREE and FOUR. Availability varies by tour and season. Display values remain strings; identifiers retain leading zeroes. Table metadata and original headers are available through DataFrame.attrs. Course duplicate headers are disambiguated (par/par_1 and dbl_bogey/dbl_bogey_1). Missing data produces an empty table; mismatched headers raise PgaTourError.

pga_tour_api.pga_scorecard_stats(tournament_id: str, player_id: str, round: str | None = None) -> pd.DataFrame

Return player tournament statistics, one row per round, section and stat.

Parameters:

Name Type Description Default
tournament_id str

Event ID, e.g. R2026030.

required
player_id str

String player ID, preserving leading zeroes.

required
round str | None

Optional round filter; "-1" selects the tournament aggregate.

None

Returns:

Type Description
DataFrame

DataFrame containing performance, scoring and strokes-gained sections.

DataFrame

Display values remain strings; numeric graph fields are preserved separately.

Source code in src/pga_tour_api/client.py
def pga_scorecard_stats(tournament_id: str, player_id: str, round: str | None = None) -> pd.DataFrame:
    """Return player tournament statistics, one row per round, section and stat.

    Args:
        tournament_id: Event ID, e.g. R2026030.
        player_id: String player ID, preserving leading zeroes.
        round: Optional round filter; "-1" selects the tournament aggregate.

    Returns:
        DataFrame containing performance, scoring and strokes-gained sections.
        Display values remain strings; numeric graph fields are preserved separately.
    """
    data = graphql_request("ScorecardStatsV3Compressed", {
        "scorecardStatsV3CompressedId": tournament_id, "playerId": player_id})
    payload = _safe_get(data, "scorecardStatsV3Compressed", "payload")
    parsed = decompress_payload(payload) if payload else {}
    columns = ["tournament_id", "player_id", "round", "round_name", "round_status",
               "section", "stat_id", "label", "short_label", "total", "rank",
               "year_to_date", "total_num", "year_to_date_num", "graph"]
    rows = []
    for item in parsed.get("rounds", []) or []:
        if round is not None and str(item.get("round")) != str(round):
            continue
        for section in ("performance", "scoring", "strokesGained"):
            for stat in item.get(section, []) or []:
                row = dict(zip(columns[:6], [tournament_id, player_id,
                    item.get("round"), item.get("displayName"), item.get("roundStatus"), section]))
                for key, upstream in zip(columns[6:], ["statId", "label", "shortLabel",
                        "total", "rank", "yearToDate", "totalNum", "yearToDateNum", "graph"]):
                    row[key] = stat.get(upstream)
                rows.append(row)
    result = pd.DataFrame(rows, columns=columns)
    result.attrs["id"] = parsed.get("id")
    return result

pga_tour_api.pga_course_stats_details(query_type: str = 'TOUGHEST_COURSE', year: int | None = None, tour: str = 'R', round: str = 'ALL') -> pd.DataFrame

Return complete course or hole rankings.

Parameters:

Name Type Description Default
query_type str

TOUGHEST_COURSE or TOUGHEST_HOLES.

'TOUGHEST_COURSE'
year int | None

Season; None uses the upstream default.

None
tour str

Tour code R, S, H or Y; availability varies.

'R'
round str

Upstream round selector; ALL combines rounds.

'ALL'

Returns:

Type Description
DataFrame

Ranking table with display values and matching *_tendency columns.

DataFrame

Original headers, season/round selectors and other metadata are in attrs.

DataFrame

Duplicate PAR headers become par and par_1; +/- becomes to_par.

Source code in src/pga_tour_api/client.py
def pga_course_stats_details(query_type: str = "TOUGHEST_COURSE",
                            year: int | None = None, tour: str = "R",
                            round: str = "ALL") -> pd.DataFrame:
    """Return complete course or hole rankings.

    Args:
        query_type: TOUGHEST_COURSE or TOUGHEST_HOLES.
        year: Season; None uses the upstream default.
        tour: Tour code R, S, H or Y; availability varies.
        round: Upstream round selector; ALL combines rounds.

    Returns:
        Ranking table with display values and matching *_tendency columns.
        Original headers, season/round selectors and other metadata are in attrs.
        Duplicate PAR headers become par and par_1; +/- becomes to_par.
    """
    _validate_tour(tour)
    if query_type not in {"TOUGHEST_COURSE", "TOUGHEST_HOLES"}:
        raise ValueError("query_type must be TOUGHEST_COURSE or TOUGHEST_HOLES")
    data = graphql_request("CourseStatsDetails", {
        "tourCode": tour, "queryType": query_type, "year": year, "round": round})
    payload = data.get("courseStatsDetails") or {}
    headers = payload.get("headers") or []
    names = make_unique_snake(["to_par" if h == "+/-" else h for h in headers])
    columns = ["rank", "display_name", "tournament_id", "tournament_name"]
    columns += [c for name in names for c in (name, name + "_tendency")]
    rows = []
    for item in payload.get("rows") or []:
        values = item.get("values") or []
        if len(values) != len(names):
            raise PgaTourError("Course ranking headers and values have different lengths")
        row = dict(zip(columns[:4], [item.get(k) for k in
                    ("rank", "displayName", "tournamentId", "tournamentName")]))
        for name, value in zip(names, values):
            row[name] = value.get("value")
            row[name + "_tendency"] = value.get("tendency")
        rows.append(row)
    result = pd.DataFrame(rows, columns=columns)
    result.attrs.update({k: v for k, v in payload.items() if k != "rows"})
    result.attrs["query_type"] = query_type
    return result

pga_tour_api.pga_record_catalog(tour: str = 'R') -> pd.DataFrame

Return the live all-time record catalogue, separate from STAT_IDS.

Parameters:

Name Type Description Default
tour str

Tour code R, S, H or Y; availability varies.

'R'

Returns:

Type Description
DataFrame

DataFrame with record_id, record_name, category_id, category, subcategory.

Source code in src/pga_tour_api/client.py
def pga_record_catalog(tour: str = "R") -> pd.DataFrame:
    """Return the live all-time record catalogue, separate from STAT_IDS.

    Args:
        tour: Tour code R, S, H or Y; availability varies.

    Returns:
        DataFrame with record_id, record_name, category_id, category, subcategory.
    """
    _validate_tour(tour)
    data = graphql_request("AllTimeRecordCategories", {"tourCode": tour})
    payload = data.get("allTimeRecordCategories") or {}
    rows = []
    for category in payload.get("categories") or []:
        for sub in category.get("subCategories") or []:
            for stat in sub.get("statistics") or []:
                rows.append([stat.get("recordId"), stat.get("displayText"),
                    category.get("categoryId"), category.get("displayText"), sub.get("displayText")])
    result = pd.DataFrame(rows, columns=["record_id", "record_name", "category_id", "category", "subcategory"])
    result.attrs["tour"] = tour
    return result

pga_tour_api.pga_all_time_records(record_id: str, tour: str = 'R') -> pd.DataFrame

Return an all-time record table, preserving the source's display values.

Parameters:

Name Type Description Default
record_id str

ID from pga_record_catalog, e.g. "2-1-11".

required
tour str

Tour code R, S, H or Y; availability varies.

'R'

Returns:

Type Description
DataFrame

DataFrame with player_id and normalized upstream headers. Metadata,

DataFrame

original headers and primaryColumnIndex are retained in attrs.

DataFrame

Source entries may contain anomalies; this is not independent validation.

Source code in src/pga_tour_api/client.py
def pga_all_time_records(record_id: str, tour: str = "R") -> pd.DataFrame:
    """Return an all-time record table, preserving the source's display values.

    Args:
        record_id: ID from pga_record_catalog, e.g. "2-1-11".
        tour: Tour code R, S, H or Y; availability varies.

    Returns:
        DataFrame with player_id and normalized upstream headers. Metadata,
        original headers and primaryColumnIndex are retained in attrs.
        Source entries may contain anomalies; this is not independent validation.
    """
    _validate_tour(tour)
    data = graphql_request("AllTimeRecordStat", {"tourCode": tour, "recordId": record_id})
    payload = data.get("allTimeRecordStat") or {}
    names = make_unique_snake(["player_id"] + (payload.get("statHeaders") or []))
    rows = []
    for item in payload.get("rows") or []:
        values = item.get("values") or []
        if len(values) != len(names) - 1:
            raise PgaTourError("Record headers and values have different lengths")
        rows.append([item.get("playerId")] + values)
    result = pd.DataFrame(rows, columns=names)
    result.attrs.update({k: v for k, v in payload.items() if k != "rows"})
    result.attrs["tour"] = tour
    return result

PGA TOUR University

pga_university_rankings(year=None, week=None) returns player rankings, schools, movement, averages and tournament history. pga_university_total_points(season=None, week=None) returns the combined points table and preserves source headers and navigation metadata in DataFrame.attrs.

pga_tour_api.pga_university_rankings(year: int | None = None, week: int | None = None) -> pd.DataFrame

Return PGA TOUR University rankings and each player's event history.

Source code in src/pga_tour_api/client.py
def pga_university_rankings(year: int | None = None, week: int | None = None) -> pd.DataFrame:
    """Return PGA TOUR University rankings and each player's event history."""
    data = graphql_request("UniversityRankings", {"year": year, "week": week})
    payload = data.get("universityRankings") or {}
    rows = []
    for player in payload.get("players") or []:
        row = {k: player.get(k) for k in (
            "playerId", "rank", "rankColor", "rankingMovement", "rankingMovementAmount",
            "rankingMovementAmountSort", "displayName", "schoolName", "country",
            "wins", "top10", "avg", "events")}
        row["tournaments"] = player.get("tournaments") or []
        rows.append(row)
    result = pd.DataFrame(rows)
    if not result.empty:
        result.columns = make_unique_snake(result.columns)
    result.attrs.update({k: v for k, v in payload.items() if k != "players"})
    return result

pga_tour_api.pga_university_total_points(season: int | None = None, week: int | None = None) -> pd.DataFrame

Return PGA TOUR University combined FedExCup/Korn Ferry points.

Source code in src/pga_tour_api/client.py
def pga_university_total_points(season: int | None = None,
                                week: int | None = None) -> pd.DataFrame:
    """Return PGA TOUR University combined FedExCup/Korn Ferry points."""
    data = graphql_request("UniversityTotalPoints", {"season": season, "week": week})
    payload = data.get("universityTotalPoints") or {}
    headers = payload.get("headers") or []
    names = make_unique_snake(headers)
    rows = []
    for player in payload.get("players") or []:
        row = {"player_id": player.get("playerId"), "player_name": player.get("playerName"),
               "rank": player.get("rank"), "rank_sort": player.get("rankSort")}
        row.update(dict(zip(names, player.get("data") or [])))
        row["tournaments"] = player.get("tournaments") or []
        rows.append(row)
    result = pd.DataFrame(rows, columns=["player_id", "player_name", "rank", "rank_sort"] + names + ["tournaments"])
    result.attrs.update({k: v for k, v in payload.items() if k != "players"})
    result.attrs["headers"] = headers
    return result

DP World Tour eligibility

pga_dp_world_tour_eligibility(year=None) wraps the PGA site's existing TourCupSplit operation with ranking ID 2700 and returns Race to Dubai eligibility standings. The source metadata is retained in DataFrame.attrs.

pga_tour_api.pga_dp_world_tour_eligibility(year: int | None = None, tour: str = 'R') -> pd.DataFrame

Return DP World Tour Race to Dubai PGA TOUR eligibility standings.

Source code in src/pga_tour_api/client.py
def pga_dp_world_tour_eligibility(year: int | None = None, tour: str = "R") -> pd.DataFrame:
    """Return DP World Tour Race to Dubai PGA TOUR eligibility standings."""
    _validate_tour(tour)
    data = graphql_request("TourCupSplit", {"tourCode": tour, "id": "2700", "year": year, "eventQuery": None})
    cup = data.get("tourCupSplit") or {}
    players = cup.get("officialPlayers") or cup.get("projectedPlayers") or []
    rows = [{"player_id": p.get("id"), "display_name": p.get("displayName"),
        "country": p.get("country"), "rank": p.get("thisWeekRank"),
        "previous_rank": p.get("previousWeekRank"),
        "movement": _safe_get(p, "rankingData", "movement"),
        "movement_amount": _safe_get(p, "rankingData", "movementAmount"),
        "points": _safe_get(p, "pointData", "official") or _safe_get(p, "pointData", "projected"),
        "tour_bound": p.get("tourBound")} for p in players]
    result = pd.DataFrame(rows)
    result.attrs.update({k: v for k, v in cup.items() if k not in {"officialPlayers", "projectedPlayers"}})
    result.attrs["ranking_id"] = "2700"
    return result

Playoff data

pga_playoff_scorecard(tournament_id) and pga_playoff_shot_details(tournament_id) wrap the PGA TOUR playoff-specific operations. A completed event may legitimately return an empty table when no playoff occurred; compressed shot payloads are decoded while preserving the upstream message and ID in DataFrame.attrs.

pga_tour_api.pga_playoff_scorecard(tournament_id: str) -> pd.DataFrame

Return playoff scorecard holes and player summaries for an event.

Source code in src/pga_tour_api/client.py
def pga_playoff_scorecard(tournament_id: str) -> pd.DataFrame:
    """Return playoff scorecard holes and player summaries for an event."""
    data = graphql_request("PlayoffScorecardV3", {"tournamentId": tournament_id})
    payload = data.get("playoffScorecardV3") or {}
    rows = []
    for playoff in payload.get("playoffs") or []:
        detail = playoff.get("playoff") or {}
        for player in detail.get("players") or []:
            person = player.get("player") or {}
            for score in player.get("scores") or []:
                rows.append({"tournament_id": tournament_id, "playoff_id": playoff.get("id"),
                    "course_name": playoff.get("courseName"), "scored_type": playoff.get("playoffScoredType"),
                    "player_id": person.get("id"), "player_name": person.get("displayName"),
                    "country": person.get("country"), "active": player.get("active"),
                    "position": player.get("position"), "hole_number": score.get("holeNumber"),
                    "score": score.get("score"), "status": score.get("status"),
                    "is_total": score.get("isTotal")})
    result = pd.DataFrame(rows)
    result.attrs["payload"] = payload
    return result

pga_tour_api.pga_playoff_shot_details(tournament_id: str) -> pd.DataFrame

Return decoded playoff shot-level data, one row per stroke.

Source code in src/pga_tour_api/client.py
def pga_playoff_shot_details(tournament_id: str) -> pd.DataFrame:
    """Return decoded playoff shot-level data, one row per stroke."""
    data = graphql_request("PlayoffShotDetailsCompressed", {"tournamentId": tournament_id})
    payload = _safe_get(data, "playoffShotDetailsCompressed", "payload")
    parsed = decompress_payload(payload) if payload else {}
    rows = []
    for hole in parsed.get("holes") or []:
        for stroke in hole.get("strokes") or []:
            rows.append({"tournament_id": tournament_id, "round": parsed.get("round"),
                "player_id": stroke.get("playerId"), "hole_number": hole.get("holeNumber"),
                "par": hole.get("par"), "hole_score": hole.get("score"),
                "stroke_number": stroke.get("strokeNumber"), "distance": stroke.get("distance"),
                "distance_remaining": stroke.get("distanceRemaining"),
                "stroke_type": stroke.get("strokeType"), "from_location": stroke.get("fromLocation"),
                "to_location": stroke.get("toLocation"), "play_by_play": stroke.get("playByPlay")})
    result = pd.DataFrame(rows)
    result.attrs["id"] = parsed.get("id")
    result.attrs["message"] = parsed.get("message")
    return result

Team events

pga_team_stroke_play_leaderboard(tournament_id) returns team standings and player membership for team-stroke events. pga_cup_team_roster(tournament_id) returns cup teams, sections and player match results.

pga_tour_api.pga_team_stroke_play_leaderboard(tournament_id: str) -> pd.DataFrame

Return team-stroke-play standings, including players and round scores.

Source code in src/pga_tour_api/client.py
def pga_team_stroke_play_leaderboard(tournament_id: str) -> pd.DataFrame:
    """Return team-stroke-play standings, including players and round scores."""
    data = graphql_request("TeamStrokePlayLeaderboardCompressed",
        {"teamStrokePlayLeaderboardCompressedId": tournament_id})
    payload = _safe_get(data, "teamStrokePlayLeaderboardCompressed", "payload")
    parsed = decompress_payload(payload) if payload else {}
    rows = []
    for team in parsed.get("leaderboard") or []:
        for player in team.get("players") or [{}]:
            rows.append({"tournament_id": tournament_id, "team_id": team.get("teamId"),
                "team_name": team.get("teamName"), "position": team.get("position"),
                "total": team.get("total"), "thru": team.get("thru"),
                "rounds": team.get("rounds"), "player_id": player.get("id"),
                "player_name": player.get("displayName"), "country": player.get("country")})
    result = pd.DataFrame(rows)
    result.attrs.update({k: v for k, v in parsed.items() if k != "leaderboard"})
    return result

pga_tour_api.pga_cup_team_roster(tournament_id: str) -> pd.DataFrame

Return cup/team event rosters and player match results.

Source code in src/pga_tour_api/client.py
def pga_cup_team_roster(tournament_id: str) -> pd.DataFrame:
    """Return cup/team event rosters and player match results."""
    data = graphql_request("CupTeamRoster", {"tournamentId": tournament_id})
    payload = data.get("cupTeamRoster") or {}
    rows = []
    for team in payload.get("teams") or []:
        for section in team.get("sections") or []:
            for player in section.get("players") or []:
                result = player.get("results") or {}
                rows.append({"tournament_id": tournament_id, "team_id": team.get("teamId"),
                    "team_name": team.get("teamName"), "section": section.get("sectionTitle"),
                    "show_results": section.get("showResults"), "player_id": player.get("playerId"),
                    "display_name": player.get("displayName"), "short_name": player.get("shortName"),
                    "wins": result.get("wins"), "ties": result.get("ties"),
                    "losses": result.get("losses"), "total": result.get("total")})
    return pd.DataFrame(rows)

pga_match_play_leaderboard(tournament_id) flattens the compressed match-play feed to one row per player per match. It preserves round, bracket/group, match score, status, tee time, seeds and country fields, and includes an upcoming flag for scheduled matches. For example, the 2023 WGC-Dell Technologies Match Play is available as R2023470.

pga_tour_api.pga_match_play_leaderboard(tournament_id: str) -> pd.DataFrame

Return one row per player in each match-play match.

Round and bracket metadata are repeated on each player row, covering both knockout brackets and round-robin groups. Upcoming matches are included when the upstream feed provides them.

Source code in src/pga_tour_api/client.py
def pga_match_play_leaderboard(tournament_id: str) -> pd.DataFrame:
    """Return one row per player in each match-play match.

    Round and bracket metadata are repeated on each player row, covering both
    knockout brackets and round-robin groups. Upcoming matches are included
    when the upstream feed provides them.
    """
    data = graphql_request("MatchPlayLeaderboardCompressed",
        {"matchPlayLeaderboardCompressedId": tournament_id})
    payload = _safe_get(data, "matchPlayLeaderboardCompressed", "payload")
    parsed = decompress_payload(payload) if payload else {}
    rows = []
    for round_data in parsed.get("rounds") or []:
        for bracket in round_data.get("brackets") or []:
            for upcoming, matches_key in ((False, "matches"), (True, "upcomingMatches")):
                for match in bracket.get(matches_key) or []:
                    for player in match.get("players") or [{}]:
                        rows.append({
                            "tournament_id": tournament_id,
                            "round": round_data.get("round"),
                            "round_header": round_data.get("roundHeader"),
                            "round_status_subhead": round_data.get("roundStatusSubHead"),
                            "round_type_subhead": round_data.get("roundTypeSubHead"),
                            "bracket_num": bracket.get("bracketNum"),
                            "bracket_header": bracket.get("bracketHeader"),
                            "match_id": match.get("matchId"),
                            "match_status": match.get("matchStatus"),
                            "match_score": match.get("matchScore"),
                            "thru": match.get("thru"),
                            "thru_number_of_holes": match.get("thruNumberOfHoles"),
                            "tee_time": match.get("teeTime"),
                            "bracket_player_swap": match.get("bracketPlayerSwap"),
                            "upcoming": upcoming,
                            "player_id": player.get("playerId"),
                            "player_name": player.get("displayName"),
                            "short_name": player.get("shortName"),
                            "first_name": player.get("firstName"),
                            "last_name": player.get("lastName"),
                            "tournament_seed": player.get("tournamentSeed"),
                            "bracket_seed": player.get("bracketSeed"),
                            "player_match_status": player.get("matchStatus"),
                            "country_flag": player.get("countryFlag"),
                            "country_name": player.get("countryName"),
                            "is_amateur": player.get("isAmateur"),
                            "record": player.get("record"),
                        })
    result = pd.DataFrame(rows)
    result.attrs.update({k: v for k, v in parsed.items() if k != "rounds"})
    return result

Editorial tables

pga_power_rankings(path) and pga_expert_picks(path) consume the content fragment path embedded in a PGA TOUR article, not the public article URL. The article page supplies paths such as /content/dam/pga-tour/fragments/.../pr-table. Nested lineups remain lists so the original editorial selection is preserved.

pga_tour_api.pga_power_rankings(path: str) -> pd.DataFrame

Return a structured editorial Power Rankings table for a content path.

Source code in src/pga_tour_api/client.py
def pga_power_rankings(path: str) -> pd.DataFrame:
    """Return a structured editorial Power Rankings table for a content path."""
    data = graphql_request("GetPowerRankingsTable", {"path": path})
    payload = data.get("getPowerRankingsTable") or {}
    rows = []
    for item in payload.get("powerRankingsTableRow") or []:
        player = item.get("player") or {}
        rows.append({"player_id": player.get("id"), "first_name": player.get("firstName"),
            "last_name": player.get("lastName"), "country": player.get("countryName"),
            "rank": item.get("rank"), "comment": item.get("comment")})
    result = pd.DataFrame(rows)
    result.attrs.update({k: v for k, v in payload.items() if k != "powerRankingsTableRow"})
    result.attrs["content_path"] = path
    return result

pga_tour_api.pga_expert_picks(path: str) -> pd.DataFrame

Return a structured editorial Expert Picks table for a content path.

Source code in src/pga_tour_api/client.py
def pga_expert_picks(path: str) -> pd.DataFrame:
    """Return a structured editorial Expert Picks table for a content path."""
    data = graphql_request("GetExpertPicksTable", {"path": path})
    payload = data.get("getExpertPicksTable") or {}
    rows = []
    for item in payload.get("expertPicksTableRows") or []:
        winner = item.get("winner") or {}
        rows.append({"expert_name": item.get("expertName"), "expert_title": item.get("expertTitle"),
            "lineup": item.get("lineup") or [], "winner_id": winner.get("id"),
            "winner_name": " ".join(x for x in (winner.get("firstName"), winner.get("lastName")) if x),
            "percent_selected": item.get("percentSelected"),
            "percent_selected_color": item.get("percentSelectedColor")})
    result = pd.DataFrame(rows)
    result.attrs.update({k: v for k, v in payload.items() if k != "expertPicksTableRows"})
    result.attrs["content_path"] = path
    return result

Season player comparisons

comparison = pga.player_comparison(["59095", "34046"], year=2026, category="SCORING")

pga_tour_api.pga_player_comparison(player_ids: list[str], category: str = 'SCORING', year: int | None = None, tour: str = 'R', tournament_id: str | None = None) -> pd.DataFrame

Compare players using PGA TOUR's season/category comparison table.

Returns one row per statistic and player. Display values remain strings; ranking and highlighting metadata are preserved.

Source code in src/pga_tour_api/client.py
def pga_player_comparison(player_ids: list[str], category: str = "SCORING",
                           year: int | None = None, tour: str = "R",
                           tournament_id: str | None = None) -> pd.DataFrame:
    """Compare players using PGA TOUR's season/category comparison table.

    Returns one row per statistic and player. Display values remain strings;
    ranking and highlighting metadata are preserved.
    """
    _validate_tour(tour)
    if not player_ids:
        raise ValueError("player_ids must contain at least one player")
    data = graphql_request("PlayerComparison", {
        "tourCode": tour, "playerIds": player_ids, "category": category,
        "year": year, "tournamentId": tournament_id})
    comparison = data.get("playerComparison") or {}
    table = comparison.get("table") or {}
    players = table.get("headerRow") or []
    rows = []
    for stat in table.get("rows") or []:
        for index, value in enumerate(stat.get("values") or []):
            player = players[index] if index < len(players) else {}
            rows.append({
                "stat_name": stat.get("statName"), "stat_id": stat.get("statId"),
                "player_id": player.get("playerId"),
                "player_name": player.get("displayText"),
                "country": player.get("country"),
                "year_data": player.get("yearData"),
                "display_value": value.get("displayValue"),
                "bold": value.get("bold"),
                "rank_deviation": value.get("rankDeviation"),
                "rank": value.get("rank"),
            })
    result = pd.DataFrame(rows, columns=["stat_name", "stat_id", "player_id",
        "player_name", "country", "year_data", "display_value", "bold",
        "rank_deviation", "rank"])
    result.attrs.update({"tour": tour, "category": comparison.get("category"),
                         "year": comparison.get("year"), "header": table.get("header"),
                         "tournament_id": tournament_id})
    return result