You can read a pitcher’s Fielding Independent Pitching off any leaderboard, but there is a particular clarity that comes from building it yourself — from turning a handful of raw counting stats into a single ERA-shaped number with your own code and watching the coefficients do their work. FIP is a fine candidate for this, because it is nothing more than three weighted events divided by innings, plus a constant, and the whole thing fits in a few lines of Python.

This is a from-scratch walkthrough. We will write the formula out, explain what the constant is doing, build a fip() function that takes a line of counting stats and returns the number, run it on a small illustrative pitching line, and finish with a note on pulling real component totals so you can do this for any pitcher you like. No libraries required for the core — just Python.

The three true outcomes

FIP starts from a deliberately narrow premise: once a ball is put in play, the pitcher has surprisingly little control over whether it becomes a hit, so we should grade him only on the outcomes that never involve a fielder. Those are the three true outcomes — strikeouts, walks (with hit-by-pitches folded in), and home runs. A strikeout is a strikeout behind any defense in any park; a walk is a walk; a home run leaves the yard regardless of who is standing in the outfield.

Everything else — the grounders, the liners, the lazy flies that drop or get caught — FIP simply refuses to look at, on the theory that the rate at which those become hits is driven by defense, positioning, and luck more than by the pitcher. Strip the balls in play away and you are left with the events a pitcher most reliably repeats season to season, which is exactly what makes FIP a better predictor of next year than this year’s ERA.

The formula, in one line

FIP weights each of those three outcomes by its run value, divides by innings pitched, and adds a league constant to put the result on the ERA scale:

FIP = ( 13×HR + 3×(BB + HBP) − 2×K ) / IP + constant

Read the coefficients and the priorities are plain. The home run carries a weight of 13 — the most damaging thing a pitcher can do, a run guaranteed with nobody on. A walk or hit-batter costs 3. A strikeout is worth −2, subtracted because it is unambiguously good and forecloses any ball in play. Notice what is absent: no singles, no doubles, no balls in play of any kind. By construction, FIP cannot be rescued by a great defense or sunk by a poor one.

What the constant does

Left alone, that fraction would land on a scale of its own that looks nothing like ERA. The FIP constant fixes that. Each season it is set so that league-average FIP equals league-average ERA, which slides the whole distribution onto the ERA scale everyone already reads — a 3.20 FIP means the same thing a 3.20 ERA does. The constant typically sits in the neighborhood of 3.10, but it is not a fixed number: FanGraphs recalculates and publishes it every year as the run environment shifts, so the value here is representative for learning the mechanics. For a real analysis, pull the exact constant for the exact season from FanGraphs. It is not a fudge factor — just the offset that aligns the two scales.

The function

Now the function itself. It takes a dictionary of a pitcher’s counting stats and the season’s constant, computes the weighted numerator, divides by innings, and adds the constant. We guard against zero innings so an empty line returns the constant instead of crashing on a divide-by-zero:

def fip(stats, fip_constant):
    numerator = (
        13 * stats["HR"]
        + 3 * (stats["BB"] + stats["HBP"])
        - 2 * stats["K"]
    )
    ip = stats["IP"]
    if ip == 0:
        return fip_constant
    return numerator / ip + fip_constant

Read it against the formula and it lines up term for term: thirteen times home runs, plus three times walks-and-hit-batters, minus twice strikeouts, all over innings, plus the constant. One bookkeeping detail worth flagging for later: innings pitched are conventionally recorded in thirds, so a line of “72.1 innings” means 72 and one-third, or 72.333 as a decimal — not 72.1. Feed the function the decimal form and the arithmetic is honest; feed it the box-score notation and you will be a hair off.

Run it on a line

Let us put it to work on a small, clearly illustrative pitching line — round numbers chosen to exercise the formula, not the real stat line of any actual pitcher. Suppose a starter, over a stretch, posts the following, and we use a representative constant of 3.10:

line = {
    "IP":  180.0,
    "K":   200,
    "BB":   45,
    "HBP":   5,
    "HR":   18,
}

print(round(fip(line, 3.10), 2))

Run that and you get a FIP in the low-3.00s — a clearly above-average mark, which fits a line carrying 200 strikeouts against only 45 walks and 18 homers. The exact figure depends on the constant you plug in, which is the whole lesson: change the season’s constant and the same counting line produces a slightly different FIP. Because the number is anchored to the ERA scale, you can sanity-check it against familiar landmarks — roughly 4.00 is league-average-ish, the low 3.00s is very good, and the 2.00s is ace territory. If your function returns a 12 or a 0.4, you have a bug, not a Cy Young.

Feeding it real counts

The dictionary above was typed by hand; the point of writing the function is to stop typing lines by hand. pybaseball will hand you real season totals as a pandas DataFrame, and from there it is a short hop to assembling the dictionary our function expects:

import pybaseball as pyb

df = pyb.pitching_stats_bref(2025)
row = df[df["Name"] == "Some Pitcher"].iloc[0]

line = {
    "IP":  float(row["IP"]),
    "K":   int(row["SO"]),
    "BB":  int(row["BB"]),
    "HBP": int(row.get("HBP", 0)),
    "HR":  int(row["HR"]),
}

print(round(fip(line, 3.10), 2))

Everything here is a straight read from the row, with two cautions worth repeating. First, confirm how your source encodes innings — if it stores the box-score “.1/.2” thirds rather than true decimals, convert them before dividing, or your FIP will drift. Second, not every backend exposes HBP as its own column, which is why we default it with row.get("HBP", 0) rather than crash; if a source genuinely lacks it, your FIP will be a touch low rather than wrong in spirit. For a published-grade number, pair real counts with the exact yearly constant from FanGraphs and the result will track the leaderboard closely. The same three inputs, with a league-average home-run-per-fly-ball rate swapped in for actual homers, are all you need to extend this into xFIP — same skeleton, one substituted term.

The bottom line

FIP looks like an advanced stat and behaves like one, but under the hood it is three weighted events over innings plus an offset you can build in an afternoon: a numerator that rewards strikeouts and punishes walks and homers, a division by innings, and a constant that parks the result on the ERA scale. Write fip() once and you can recompute the number for any pitcher, any season, the moment you have the counts — just remember the constant is a yearly download, not a fixed value, and that the 3.10 here is representative rather than exact. The same instinct that built this function — grade the controllable, ignore the rest — is the entire philosophy behind why FIP exists in the first place.

Sources & Further Reading

  • FanGraphs Library — the FIP entry and the annual table publishing each season’s exact FIP constant.
  • FanGraphs — season FIP leaderboards to check your computed numbers against.
  • Baseball-Reference — component pitching totals (K, BB, HBP, HR, IP) for feeding the function real data.