Who Runs Out of Cash First? A Runway Post-Mortem in pandas
Most portfolio reviews have one column people read first: runway, the months of cash a startup has left. Sort by it, shortest first, and the top of the list is where the meeting starts.
The portfolio here is Lanternfield’s. Lanternfield is an invented accelerator and seed fund, and its records are practice data on LucidPlexus: 1,000 applications, 91 of them funded, and for each funded startup one row a month (revenue, customers, burn and cash) from the month after the cheque until March 2026, or until it closed or was sold. The startups, the founders and the money are all made up, with a few faults planted for learners to find. We asked the records the portfolio question: who runs out of cash first?
monthly_metrics.csv1,817 rows · 8 columns · CSV →Lanternfield — Funding outcomes funding_outcomes.csv1,000 rows · 6 columns · CSV →data next to your notebook and every snippet below runs as written in pandas. They are the same files this post-mortem used, faults included.Burn, runway, and one month too many
Two definitions first. Net burn is what a startup spends in a month minus what it earns, so a negative burn means it’s making money. Runway is cash divided by burn: the months until the money runs out if nothing changes. It only means something when the burn is positive. A startup that earns more than it spends doesn’t have a runway; it has a business. Then the checks, before a single number gets quoted:
import pandas as pd
monthly = pd.read_csv("data/monthly_metrics.csv", parse_dates=["Month"])
monthly.columns = ["business", "month", "revenue", "customers",
"new", "lost", "burn", "cash"]
outcomes = pd.read_csv("data/funding_outcomes.csv", parse_dates=["Decision Date"])
outcomes.columns = ["business", "stage", "decided", "cheque", "stake", "status"]
print(len(monthly), "rows,", monthly.duplicated().sum(), "exact copy")
monthly = monthly.drop_duplicates().sort_values(["business", "month"])
gap = monthly.groupby("business")["month"].diff().dt.days > 31
print(monthly.loc[gap, ["business", "month"]].to_string(index=False))1817 rows, 1 exact copy
business month
Business 99 2023-08-01
The checks turn up one month listed twice (Business 43, December 2022), which would count that month’s burn twice in any total, so it goes. Business 99 has a gap: nothing for June and July 2023, which matters for anything that counts months. A third planted fault, a negative number of lost customers, doesn’t touch burn or cash. Now the runway, from each startup’s latest month:
funded = outcomes[outcomes["stage"] == "Funded"].set_index("business")
latest = (monthly.groupby("business").tail(1).set_index("business")
.join(funded[["status", "cheque"]]))
active = latest[latest["status"] == "Active"].copy()
active["runway"] = active["cash"] / active["burn"].where(active["burn"] > 0)
print((active["burn"] <= 0).sum(), "of", len(active),
"active startups earn more than they spend")
print(active.sort_values("runway")[["cash", "burn", "runway"]]
.head(5).round(1))22 of 41 active startups earn more than they spend
cash burn runway
business
Business 554 122729 32164 3.8
Business 607 767131 104225 7.4
Business 688 392802 49796 7.9
Business 571 131850 15811 8.3
Business 719 719147 82262 8.7
Of the 19 that burn cash, Business 554 has 3.8 months left, and four more have less than nine. So Business 554 runs out first, in about four months. Unless runway is a poor clock. It is, after all, a forecast: it assumes next month looks like this one. Forecasts can be checked against the past, and Lanternfield’s past includes startups that closed.
Runway’s track record
The test is simple. For each closed startup, take its runway in its first month, fresh from the cheque, and set it against how long the startup actually lasted:
first = (monthly.groupby("business").head(1).set_index("business")
.join(funded[["status", "cheque", "decided"]]))
first["burning"] = first["burn"] > 0
first["runway"] = first["cash"] / first["burn"].where(first["burning"])
span = monthly.groupby("business")["month"].agg(["min", "max"])
first["months"] = ((span["max"].dt.year - span["min"].dt.year) * 12
+ span["max"].dt.month - span["min"].dt.month + 1)
closed = first[first["status"] == "Closed"]
burners = closed[closed["burning"]]
print(len(burners), "closed startups burned cash in their first month")
print("median runway in month one:", burners["runway"].median().round(1))
print("median months they lasted: ", burners["months"].median())
print("closed before it ran out: ",
(burners["months"] < burners["runway"]).sum(), "of", len(burners))18 closed startups burned cash in their first month
median runway in month one: 25.0
median months they lasted: 19.0
closed before it ran out: 15 of 18
Fifteen of the 18 closed before their first-month runway ran out. Median runway was 25 months; the median closed startup lasted 19. Their last reports still show money in the bank: $129,049 at the median. Runway measures the time to zero, and companies don’t wait for zero; one that can’t raise again winds down while it can still pay its people. So runway overstates the time left. Discount it and move on? Not yet. The first-month numbers were hiding something more useful than a discount.
What the closed ones had in common
Look again at that output: nearly every startup that closed was burning cash in its very first month. That isn’t how most of the portfolio started: 42 of the 91 funded startups earned more than they spent from month one. So here’s a sharper test, on the intakes old enough to have had time to fail, the 39 startups funded in 2022 and 2023:
old = first[first["decided"].dt.year <= 2023]
print(pd.crosstab(old["burning"], old["status"]))status Acquired Active Closed Raised Follow-on
burning
False 3 6 0 7
True 0 1 13 9
Of the 16 that earned money in their first month, none has closed: they’re active, sold, or have raised a follow-on round. Of the 23 that burned, 13 have closed. Burning from the start isn’t a death sentence, mind: 9 of those 23 raised a follow-on round, the best news a seed fund gets short of a sale. But every closure in those two intakes came from the burners.
That leaves the useful question. Month one comes after the money has been paid. Could the fund have seen this coming before it wrote the cheque? There is one number in these records that’s fixed before month one begins.
Then we looked at the cheques
The cheque. Lanternfield sets its size when it decides to invest, before the startup’s first month on the books, so put it beside the month-one burn:
print(pd.crosstab(first["cheque"] > 150_000, first["burning"],
rownames=["cheque over $150,000"],
colnames=["burning in month one"]))burning in month one False True
cheque over $150,000
False 42 5
True 0 44
Big cheque, burning startup.
Every single time.
Read the corners. All 42 startups that earned money from month one received exactly $150,000, the smallest cheque in Lanternfield’s records. All 44 cheques above that, up to $500,000, went to startups that burned from the start, and 16 of those have closed. In these records, a cheque’s size says less about how well a startup would do than about how hard it would burn, and that was known on the day the cheque was signed. (A handful of startups that earned in month one have closed too, all from the 2024 intake and most of them after a single month of records. The data doesn’t say why.)
And the portfolio today?
burning_now = active[active["burn"] > 0]
print((burning_now["cheque"] > 150_000).sum(), "of", len(burning_now),
"startups burning today had a cheque over $150,000")18 of 19 startups burning today had a cheque over $150,000
That means the five shortest runways in Fig. 1 belong to four big-cheque startups and Business 571. The list everyone reads first was, in a sense, written when the cheques were.
- Compute it only where burn is positive, and say what happened to the rest:
where(burn > 0)leaves them asNaN, not a negative number of months. - Date it. “3.8 months” means 3.8 months from March 2026, at March’s burn.
- Check it against the ones that already ended. Here it overstated the time left for 15 of 18.
Every snippet above ran in pandas 3.0.5 on the two practice files, and every output is pasted as it printed.
The committee meets in April
Lanternfield’s committee meets in April with a spring round to decide: $2.5 million and room for at most ten cheques. Of the 19 cheques over $150,000 it wrote in 2022 and 2023, 12 went to startups that have since closed and 7 to startups that raised a follow-on round. Not one is simply ticking over. Big cheques, here, meant all or nothing.
What the records can’t say is which way the arrow points. Did startups that were going to burn ask for big cheques, or did big cheques give startups something to burn? Either way, the committee has ten cheques to place this spring. Is a startup that needs a big one a worse bet, or only a bigger one?
References
- Lanternfield — Monthly metrics (Community dataset) — one row a month per funded startup: the file the runway snippets read.
- Lanternfield — Funding outcomes (Community dataset) — the thousand applications: how far each got, and the cheque and stake where one was written.
- Worth Backing? (Academy course) — SQL and Python for startup data and investment decisions, on these six tables.
- Worth Backing? (guided project) — the spring round as seventeen tasks with automatic checks.
- Worth Backing? (Library) — the field book: one thousand applications, ten cheques.
- Startup Metrics in SQL and Python (cheat sheet) — growth, churn, burn and runway as of a date, on the same monthly records.
- Startup Metrics Dashboard (template) — type a startup’s monthly numbers and read its growth, churn and runway on one page.
- Investment Committee Memo (template) — putting a recommendation like this one in front of a committee.
- pandas documentation: Series.where — keeping values where a condition holds and leaving NaN elsewhere.
- pandas documentation: Group by (split-apply-combine) — head() and tail() as filters that keep the first or last rows of each group.
- pandas documentation: pandas.crosstab — cross-tabulating two factors, as in the cheque table.
Comments
No comments yet. Be the first to share your thoughts.