The year was 1998. Britney Spears topped the charts, Bill Clinton was impeached, and a couple of little websites called Google and Ars Technica came online. It was also the year Nintendo released The Legend of Zelda: Ocarina of Time on the Nintendo 64. Google, which has come to dominate the Internet (Ars is still working on that), is celebrating the imminent release of the Ocarina of Time remaster by temporarily transporting us back to that storied year with a search Easter egg.
To access the Google time machine, you just have to search for either "Zelda" or "Ocarina of Time." Google will load the standard 2026 results page, but you’ll see an animated Master Sword in the lower right corner. It appears on both mobile and desktop—just click the sword and enjoy the show.
After a little time-skipping animation and a Link shout (you know the one), you’ll swear it’s 1998 again. The search page is transformed into a simple list of blue links with retro Google branding. This is particularly apt given the time-shifting mechanics in Ocarina of Time.
Since the 1970s, self-taught engineer Hugh Piggott has been building wind turbines from scratch in his native Scotland. In the 2000s, he started holding workshops showing participants how to build their own. Jonathan Schreiber, an Austrian renewable-energy practitioner, took Piggott’s course in 2012.
Piggott no longer teaches, but now Schreiber has picked up the mantle. He formed PureSelfMade, an independent-energy education project, and since 2013 has hosted workshops showing participants how to build Piggott’s design.
At these workshops, you do not assemble a wind turbine kit. Instead you literally build the entire thing, including the generator, from scratch. You learn to carve the blades, wind copper wire to make the coils, use an angle grinder to cut the steel, et cetera:
PureSelfMade’s upcoming wind turbine workshops are held in Germany and Austria, and conducted in German. They’re also working on setting up English-language workshops in the UK.
Short answer: $10,000 put into SPY on January 3, 2000 grew to $83,195 by October 30, 2026 with every dividend reinvested. Taking the dividends as cash left you with $59,123: $51,962 in shares plus $7,161 in collected dividends. That makes reinvesting worth $24,072, or 41% more money, on a fund that yields only about 1% a year.
This dividend reinvestment calculator doesn’t assume a smooth 7% return. It replays every real SPY, VYM, SCHD and QQQ dividend from Yahoo Finance, day by day, with and without reinvestment. The Python script is below, so you can run it on your own tickers.
TL;DR
$10,000 in SPY from Jan 2000: $83,195 with dividends reinvested vs $59,123 taking cash, a +$24,072 (41%) edge.
Since SPY’s 1993 launch: $314,411 vs $197,803, +59%. Edge scales with yield × years (QQQ +14%, VYM +43%).
The DRIP account was behind in 2000–2002 and 2008, and ahead only 3.2% by 2011. The payoff came in the second decade.
With $500/month added: $1,049,823 vs $846,717, a $203,106 gap.
Reinvested dividends are still taxed in taxable accounts; a 15% rate cut the result to $77,535. Python script included.
How the calculator works (assumptions labeled)
The script, drip.py, does what a broker’s DRIP does, and labels its simplifications:
Data: daily closes plus per-share dividends from Yahoo Finance via yfinance, through October 30, 2026.
Assumption 1: each dividend is reinvested at the close on its ex-date. Real brokers buy on the payment date, a few days later. That’s a small difference over 27 years.
Assumption 2: in the “no DRIP” case the cash just sits there at 0%. This is the honest version of “I’ll spend it or leave it in the account”.
Assumption 3: no taxes or fees unless I say so. Taxes get their own section below.
As a sanity check, Yahoo’s dividend-adjusted price series turns the same $10,000 into $83,131. My day-by-day replay lands at $83,195, a 0.1% difference. So the replay matches total return.
The results: DRIP vs cash, five real histories
ETF
Start
Years
$10,000 with DRIP
$10,000, dividends as cash
DRIP edge
SPY
Jan 03, 2000
26.8
$83,195 (8.22%/yr)
$59,123 (6.85%/yr)
+$24,072 (+41%)
SPY
Jan 29, 1993
33.7
$314,411 (10.76%/yr)
$197,803 (9.25%/yr)
+$116,608 (+59%)
VYM
Nov 16, 2006
20.0
$59,485 (9.35%/yr)
$41,668 (7.41%/yr)
+$17,818 (+43%)
SCHD
Oct 20, 2011
15.0
$69,132 (13.73%/yr)
$54,081 (11.89%/yr)
+$15,050 (+28%)
QQQ
Jan 03, 2000
26.8
$86,575 (8.38%/yr)
$75,786 (7.84%/yr)
+$10,789 (+14%)
Yahoo Finance daily closes + dividends to Oct 30, 2026; cash case = shares + uninvested dividends at 0%; no taxes or fees. drip.py
The SPY row is the headline. Without reinvestment you still owned your original 68.8 shares. With it, quarterly dividends quietly bought another 41 shares, so you finish with 110.1 shares. Each of those shares is now worth about $756 and pays its own dividends.
Go back further and the gap widens. From SPY’s first trading day in January 1993, $10,000 grew to $314,411 with DRIP versus $197,803 without. That’s $116,608 (59%) more, and the only difference is what you did with a 1–2% yield.
The DRIP edge scales with yield and years: from +14% for low-yield QQQ to +59% for SPY since 1993 (drip.py, Yahoo Finance data).
Two dials decide how much reinvesting adds:
Yield. QQQ paid only 0.45% over the last 12 months, so 27 years of reinvesting added just 14%. VYM, at 2.25%, added 43% in only 20 years.
Time. SCHD yields the most (2.95%), but its history only starts in October 2011. Fifteen years of reinvesting added 28% ($15,050). It simply hasn’t had SPY’s 33 years yet.
Napkin rule: the DRIP edge grows roughly with yield × years. A 1% yield over 27 years (SPY) and a 2.25% yield over 20 years (VYM) both landed near +41–43%.
The surprise: reinvesting barely mattered for 12 years
Here’s the part no compound-interest calculator shows you. Reinvesting doesn’t pull ahead in a straight line. In the SPY run that started in 2000, the DRIP account was behind the cash account at the end of 2000, 2001, 2002 and 2008.
Reinvesting barely mattered for 12 years (−3.2% in 2008), then compounding took the lead to 40.7%.
The reason is simple. Every dividend reinvested in 2000–2002 bought shares that then fell during the dot-com crash. The cash version held those dollars at full value. At the end of 2008 the DRIP account was 3.2% behind. Its lead was still only 3.2% at the end of 2011.
After that, compounding took over. The lead hit 10% in 2013, 26% in 2020, and 40.7% by October 30, 2026. If you check your DRIP after a few years and see nothing, that’s normal. The payoff comes in the second decade.
People rarely reinvest for the final balance alone. They want a larger dividend check later. So the calculator multiplies the last 12 months of per-share payouts by the shares you hold now:
Reinvested shares pay their own dividends: SPY income is 60% higher ($835 vs $521 a year).
From one $10,000 start, SPY now pays $835 a year to the DRIP investor versus $521 to the cash investor, 60% more income from the same original money. SCHD pays $2,038 versus $1,267. With reinvestment, each new share pays dividends too, so the income grows on its own.
With monthly contributions the gap reaches six figures
Most people don’t invest once and walk away. So I ran the calculator again with $10,000 up front plus $500 on the first trading day of every month into SPY from January 2000. That’s $170,500 contributed in total (the same approach as the monthly plan in my lump sum vs DCA on the S&P 500 backtest).
SPY scenario (Jan 2000–Oct 2026)
Contributed
With DRIP
Dividends as cash
Gap
$10,000 once
$10,000
$83,195
$59,123
+$24,072 (+41%)
$10,000 + $500/month
$170,500
$1,049,823
$846,717
+$203,106 (+24%)
$10,000 once, 15% tax on dividends (assumption)
$10,000
$77,535
n/a
−$5,660 vs untaxed DRIP
drip.py on Yahoo Finance data. Tax row is a labeled assumption, not advice.
With reinvestment the account crosses $1,049,823. Without it, it reaches $846,717. The difference is $203,106, more than the total amount contributed. Monthly money shrinks the gap in percentage terms (24% vs 41%), because fresh cash hasn’t had time to compound. In dollars, though, the gap grows a lot.
Taxes: reinvesting doesn’t dodge them
In a taxable US account, the IRS treats reinvested dividends exactly like cash dividends. Publication 550 says that if you use your dividends to buy more stock, “you must still report the dividends as income”. So DRIP isn’t a tax strategy. It’s a compounding strategy.
Qualified dividends are taxed at long-term capital gain rates (IRS Topic 404). For 2025 those rates are 0%, 15% or 20% depending on taxable income, and “no higher than 15% for most individuals” (IRS Topic 409).
Assumption: a flat 15% on every SPY dividend, with tax paid out of the dividend before reinvesting. The DRIP result falls from $83,195 to $77,535, a $5,660 drag over 27 years. In a tax-advantaged account like an IRA or 401(k), there’s no yearly drag and the untaxed numbers above apply.
Don’t double count: if you test DRIP yourself, replay dividends on the raw close, or use the adjusted close alone. Doing both counts every dividend twice and inflates the result.
The Python dividend reinvestment calculator
Install yfinance and pandas, then save the prices once (so reruns don’t hit Yahoo every time) and run the script:
import yfinance as yf, pandas as pd
d = {t: yf.Ticker(t).history(start="1993-01-01", end="2026-11-01", auto_adjust=False, actions=True)
for t in ["SPY", "SCHD", "VYM", "QQQ"]}
pd.to_pickle(d, "src/prices.pkl")
auto_adjust=False keeps the raw (split-adjusted) close and the separate Dividends column. If you replayed dividends on top of the dividend-adjusted close, you’d count them twice.
"""Dividend reinvestment calculator: replay real ETF history (Yahoo Finance closes + dividends),
with vs without reinvesting dividends, optional monthly contributions and dividend tax."""
import pandas as pd
def load(path="src/prices.pkl"):
d = pd.read_pickle(path)
return {t: h[["Close", "Dividends"]].tz_localize(None).loc[:"2026-10-30"] for t, h in d.items()}
def drip(h, start, initial=10_000, monthly=0, reinvest=True, tax=0.0):
"""Daily replay. Dividends paid on the ex-date at that day's close (a common approximation)."""
h = h.loc[start:]
shares, cash, paid, invested, month, rows = 0.0, 0.0, 0.0, 0.0, None, []
for day, (px, div) in h.iterrows():
if month is None:
shares, invested = initial / px, initial
elif day.month != month and monthly:
shares += monthly / px; invested += monthly
month = day.month
if div > 0:
net = shares * div * (1 - tax); paid += net
if reinvest: shares += net / px
else: cash += net
rows.append((day, shares * px + cash, shares * px, cash, invested, paid))
return pd.DataFrame(rows, columns=["date", "total", "stock", "cash", "invested", "divs"]).set_index("date")
def cagr(s):
yrs = (s.index[-1] - s.index[0]).days / 365.25
return (s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1
if __name__ == "__main__":
P = load()
for t, start in (("SPY", "2000-01-03"), ("SPY", "1993-01-29"), ("SCHD", "2011-10-20"), ("VYM", "2006-11-16"), ("QQQ", "2000-01-03")):
a, b = drip(P[t], start), drip(P[t], start, reinvest=False)
yrs = (a.index[-1] - a.index[0]).days / 365.25
print(f"{t} {a.index[0]:%b %Y}-{a.index[-1]:%b %Y} ({yrs:.1f} yrs) $10k: DRIP ${a.total.iloc[-1]:,.0f} "
f"(CAGR {cagr(a.total):.2%}) | no DRIP ${b.total.iloc[-1]:,.0f} = ${b.stock.iloc[-1]:,.0f} shares + ${b.cash.iloc[-1]:,.0f} cash "
f"(CAGR {cagr(b.total):.2%}) | DRIP edge ${a.total.iloc[-1]-b.total.iloc[-1]:,.0f} ({a.total.iloc[-1]/b.total.iloc[-1]-1:.0%}) | "
f"shares {a.stock.iloc[-1]/P[t].Close.iloc[-1]:.1f} vs {b.stock.iloc[-1]/P[t].Close.iloc[-1]:.1f}")
a = drip(P["SPY"], "2000-01-03", monthly=500); b = drip(P["SPY"], "2000-01-03", monthly=500, reinvest=False)
print(f"SPY 2000 $10k + $500/mo (invested ${a.invested.iloc[-1]:,.0f}): DRIP ${a.total.iloc[-1]:,.0f} | no DRIP ${b.total.iloc[-1]:,.0f}")
t15 = drip(P["SPY"], "2000-01-03", tax=0.15)
print(f"SPY 2000 $10k DRIP with 15% dividend tax: ${t15.total.iloc[-1]:,.0f}")
# annual dividend income at the end (last 12 months of per-share payouts x shares held)
for t, start in (("SPY", "2000-01-03"), ("VYM", "2006-11-16"), ("SCHD", "2011-10-20"), ("QQQ", "2000-01-03")):
ttm = P[t].Dividends.loc["2025-10-31":].sum(); px = P[t].Close.iloc[-1]
a, b = drip(P[t], start), drip(P[t], start, reinvest=False)
print(f"{t} trailing-12m payout ${ttm:.3f}/share = {ttm/px:.2%} yield; yearly income DRIP ${ttm*a.stock.iloc[-1]/px:,.0f} vs no DRIP ${ttm*b.stock.iloc[-1]/px:,.0f}")
Output on October 30, 2026 data:
SPY Jan 2000-Oct 2026 (26.8 yrs) $10k: DRIP $83,195 (CAGR 8.22%) | no DRIP $59,123 = $51,962 shares + $7,161 cash (CAGR 6.85%) | DRIP edge $24,072 (41%) | shares 110.1 vs 68.8
SPY Jan 1993-Oct 2026 (33.7 yrs) $10k: DRIP $314,411 (CAGR 10.76%) | no DRIP $197,803 = $171,999 shares + $25,804 cash (CAGR 9.25%) | DRIP edge $116,608 (59%) | shares 416.0 vs 227.6
SCHD Oct 2011-Oct 2026 (15.0 yrs) $10k: DRIP $69,132 (CAGR 13.73%) | no DRIP $54,081 = $42,970 shares + $11,111 cash (CAGR 11.89%) | DRIP edge $15,050 (28%) | shares 1932.1 vs 1201.0
VYM Nov 2006-Oct 2026 (20.0 yrs) $10k: DRIP $59,485 (CAGR 9.35%) | no DRIP $41,668 = $32,449 shares + $9,218 cash (CAGR 7.41%) | DRIP edge $17,818 (43%) | shares 363.3 vs 198.2
QQQ Jan 2000-Oct 2026 (26.8 yrs) $10k: DRIP $86,575 (CAGR 8.38%) | no DRIP $75,786 = $72,875 shares + $2,911 cash (CAGR 7.84%) | DRIP edge $10,789 (14%) | shares 125.4 vs 105.5
SPY 2000 $10k + $500/mo (invested $170,500): DRIP $1,049,823 | no DRIP $846,717
SPY 2000 $10k DRIP with 15% dividend tax: $77,535
SPY trailing-12m payout $7.583/share = 1.00% yield; yearly income DRIP $835 vs no DRIP $521
VYM trailing-12m payout $3.676/share = 2.25% yield; yearly income DRIP $1,335 vs no DRIP $728
SCHD trailing-12m payout $1.055/share = 2.95% yield; yearly income DRIP $2,038 vs no DRIP $1,267
QQQ trailing-12m payout $3.091/share = 0.45% yield; yearly income DRIP $388 vs no DRIP $326
To use it for your own holdings, change the ticker, start, initial, monthly and tax. If you also want to see whether your picks beat the index after dividends, my guide to comparing your portfolio to the S&P 500 uses the same total-return logic.
What to do with this
Question
What the data says
What to do
How much is DRIP worth?
+14% (QQQ) to +59% (SPY since 1993) more ending value
Turn it on unless you need the income
When does it pay off?
SPY lead ≤3.2% through 2011, 40.7% by 2026
Judge it after a decade, not a year
Does it help income?
SPY: $835 vs $521 a year from the same $10k
Reinvest during accumulation, take cash later
Does it avoid tax?
No: reinvested dividends are still taxable (IRS Pub 550)
Prefer IRA/401(k) for high-yield funds
Is high yield better?
QQQ (0.45% yield) still ended at $86,575 vs SPY $83,195
Pick funds on total return, not yield
Sources: drip.py on Yahoo Finance data; IRS Publication 550, Topics 404 and 409. History, not a forecast.
Turn DRIP on in tax-advantaged accounts unless you need the income now. Across these five histories, that one setting was worth 14–59% more money.
In taxable accounts, reinvest anyway if you were going to reinvest by hand. The tax is the same either way. DRIP just stops cash from sitting idle.
Judge it after 10+ years, not 3. The SPY run was behind or within about 3% of the cash version for its first 12 years.
Don’t chase yield for the DRIP edge alone. QQQ’s tiny 0.45% yield still finished with a higher DRIP balance than SPY ($86,575 vs $83,195), because price growth did the work.
Limitations: ex-date reinvestment, 0% on idle cash, no fees, and one 15% tax assumption. SCHD and VYM have shorter histories. This is history, not a forecast, and not investment advice.
From calculator to finance ebook
If you’ve worked through numbers like these, you already have the outline of a short, useful ebook: “DRIP vs cash, with real data”. You could use it as a lead magnet for a finance newsletter or sell it on KDP. ImagineYourBook is an AI book-writing tool that turns a prompt or your notes into a complete draft with a designed cover and Word and EPUB downloads. It also has a Manuscript Rewrite feature for updating an existing book, for example when next year’s dividend data comes in.
Takeaway: reinvesting SPY’s roughly 1% yield turned $10,000 into $83,195 instead of $59,123. The edge was invisible for 12 years and worth 41% after 27. Run the script on your own tickers, keep DRIP on, and give it a decade.
If you want to package what you learned into a book, ImagineYourBook‘s trial is $0 today with a card required. It includes one 10,000-word Small Book, the first $29.99 charge comes after 7 days, and you can cancel any time before day 8. Treat the result as a draft: check every number before you publish.
Probably everyone responsible for managing database servers at some point realizes that it would be helpful to have a tool capable of extracting data directly from raw database files. Something completely offline, without the need to have a running database server, and that would work with data directories, files, or binary backups (storage volume snapshots, XtraBackup, etc).
There are a couple of use cases for that I could think of:
Crashed/corrupted data recovery – if the database service refuses to start or enters a crash-restart loop and there are no good backups (PITR!) available.
Backup (additional) verification and data files health check.
Extraction of individual databases or tables from a full backup.
Quick provisioning of staging/QA instances directly from live production.
Recovery of accidentally deleted rows.
Forensics (i.e., quick seek for data across many old backups without the need to reload them).
Import/migrate data from very old/legacy database versions.
Backups!
Here, let me drop an obvious yet often neglected statement: nothing will replace good backups! Your database infrastructure should always be planned with the assumption that your data may get damaged or lost at any time. Visit here if you want to check if your current backup policy is up to date: https://www.percona.com/blog/mysql-backup-and-recovery-best-practices/
Now, would an offline tool with the ability to read SQL data from files be another backup method? NO! Modern ACID- and MVCC-compliant data engines are too complex in terms of data durability and consistency. So, reading directly from database files does not guarantee the dump would be consistent. Dedicated backup tools like XtraBackup engage additional steps to ensure consistency, like following the redo log and using backup locks.
Corruption and Crash Recovery
Typically, when MySQL / MariaDB server detects severely corrupted data, it may need to perform an emergency shutdown with the intention of preventing further damage. A lot depends on the scope of the damage. Some minor issues with secondary indexes could often be fixed by re-creating the index, but clustered key corruption often leads to an engine crash whenever the corrupted page is accessed. You may find yourself in a situation similar to these reports:
Again, with good backups, this should be no problem. But a bad surprise may force you to try to read all healthy rows in the hope of restoring as much as possible. The task will be, in most cases, very tedious – an attempt to read a row sitting in a corrupted page would crash the server, so you have to wait for a restart before you can try reading the next row.
This was one of the reasons for me to try to come up with a tool fully capable of extracting rows without needing to run MySQL. Otherwise, a challenging project was affordable using vibe-coding, where the agent could build data decoding by reading the relevant engine’s source code. So, let me introduce a new open-source project: innodump! Up to this point, I have implemented all three most popular MySQL and MariaDB engines: InnoDB, MyISAM, and MyRocks (RocksDB). The tool can read and dump the whole data directory, or specific tables, either from a common or general tablespace or from a per-table file. It decodes both the table schema and rows into SQL files or a TSV structure compatible with MySQL Shell dump utilities. The latter allows you to load the dump into another instance very efficiently.
There is an option relevant from the corruption point of view:
--skip-corrupted – in case a corrupted data page is encountered, it prints a comment and skips to the next page, effectively reading as much data as possible.
Data file:sqldump_2026–10–21_16.42/db1.instant_t–data.sql(100680row(s)written,1corrupted page(s)skipped–see warnings above)
Data Verification
--examine – this option makes the tool read every table and decode every row without storing them; it produces a report of how many rows can be found and how many of them are corrupted.
Note: the tool scans only the clustered (primary) index, so it will not find possible secondary index corruptions; it is expected to only ensure which rows can be restored.
Following/data/sandboxes/msb_11_6_2/data/recursively—recognized asasingle instance‘s datadir (found ibdata1), so every InnoDB table found inside (file-per-table .ibd files, plus any table found only in ibdata1’sown shared tablespace)andevery MyISAM table(.MYD files),plus every MyRocks table(read from#rocksdb) will be examined (nothing is written).
Source:/data/sandboxes/msb_11_6_2/data/(6.ibd file(s),129.frm–only table(s)toresolve via ibdata1,aMyRocks data directory(#rocksdb) found)
Note:129.frm file(s)with no matching.ibd had no InnoDB entry inibdata1‘sown dictionary either–likely non–InnoDB tables,VIEWs,orageneral tablespace–notexamined
Result:every table read cleanly
If you are looking for a way to scan InnoDB files for potential corruption, you should always start with the official innochecksum. With innodump’s examination, the main goal is to show what’s actually readable.
Staging/dev/QA Data Provisioned the Easy and Unintrusive Way?
Quite often, a DBA may be tasked with setting up a non-production MySQL or MariaDB instance having a recent data snapshot from production. And typically, this kind of data copy for staging purposes does not have to be point-in-time consistent. And note that using this tool on a live instance or on an unprepared backup will mean various data consistency issues, like dirty pages may not be on disk yet, pages can be mid-write, it can produce rows that are missing, stale, or wrongly flagged, and even whole tables can be missing if created recently enough inside a common space.
Now, if the last regular available backup is up to date enough, it may still be too costly to restore the full backup while only a handful of databases are needed. Here, innodump can dump from specific database folders or files:
Following/data/sandboxes/msb_9_7_0/data/db2/recursively—recognized asasingle database directory(no subdirectories of its own),so every InnoDB table found inside(file–per–table.ibd files,plus any table found only inibdata1‘sown shared tablespace)andevery MyISAM table(.MYD files)will be dumped.
What if no recent backup is available but there is a need to fetch staging data quickly from production? Bearing in mind that reading from a live datadir has the caveats above, which is fine for a throwaway staging copy, the tool can do it gently, as it allows you to limit the IO throughput during exporting. Moreover, it can bypass the OS page cache via the O_DIRECT flag, so as not to trash the server’s cache with garbage and avoid causing additional memory pressure (the kernel might decide to swap out some least recently used user space memory to make room for page cache!). Therefore, to make the export from production with as little impact as possible, I recommend using --io-limit (RATE bytes per second) and --direct-io options, plus being gentle on the CPU side, running the tool with nice.
InnoDB does not immediately purge or overwrite deleted rows in the table spaces. The record is first only delete-marked and stays in the page. If no new writes have actually reclaimed the space yet, such rows usually can be decoded. So, if done quickly, there is a good chance you can restore those rows, and innodump makes it easy via the --deleted-only option!
Data file:sqldump_2026–10–21_17.46/sbtest.sbtest1–data–deleted.sql(2100delete–marked row(s)written)
I hope you may find the tool handy for the cases mentioned above as well as possibly others, like extracting data from some ancient/legacy database versions, where the regular backup tools don’t even work.
Note: it is not an officially supported Percona software, but rather an experimental personal project.
The tool can be found on GitHub, and all feedback is welcome!
How often to rebalance a portfolio? Once a year is enough for most people. I backtested a 60/40 portfolio on real Vanguard fund data from December 1986 to September 2026. Rebalancing every December took 39 trades. Monthly rebalancing took 477. Both landed on the same Sharpe ratio (0.64 vs 0.63), and the yearly version actually had the smaller worst drawdown (−30.8% vs −32.5%).
Never rebalancing is the one choice that really changes the result. Leave a “60/40” alone for 40 years and it ends up 93.9% stocks. You get a higher return, but you’re also holding a much riskier portfolio than the one you picked.
Below you’ll find the numbers, the charts, and the full Python script, so you can rerun it on your own funds.
TL;DR
Once a year is enough: 39 yearly rebalances matched 477 monthly ones on Sharpe ratio (0.64 vs 0.63) in a 60/40 VFINX/VBMFX backtest, Dec 1986 to Sep 2026.
Yearly rebalancing even had the smaller worst drawdown: −30.8% vs −32.5% for monthly.
A 5-point band (trade only when stocks leave 55-65%) needed just 26 trades for the best Sharpe (0.65).
Never rebalancing earned more ($442,046 vs $312,520 for yearly, from $10,000) but drifted to 93.9% stocks and fell −38.7% in 2008-09.
Across 358 rolling 10-year windows, monthly vs yearly differed by a median of only −0.11 points a year. Python script included.
The setup: two real funds, five rules
I wanted to test funds you could actually have owned, not idealized indexes:
Stocks: VFINX (Vanguard 500 Index Fund, tracks the S&P 500)
Bonds: VBMFX (Vanguard Total Bond Market Index Fund)
Both series are Yahoo Finance adjusted closes, so dividends and interest are reinvested and fund fees are already included. Each test starts with $10,000 at 60% stocks and 40% bonds on December 31, 1986, and runs on month-end data for 477 months through September 30, 2026. The 3-month T-bill rate from FRED is the cash rate for the Sharpe ratio.
The only thing that changes between tests is the rebalancing rule:
Never (buy and hold)
Monthly, back to 60/40 at every month-end
Quarterly, every March, June, September and December
Once a year, every December
5-point band: check every month, but only trade when stocks drift more than 5 percentage points from 60% (below 55% or above 65%)
Here is the script. It needs pip install yfinance pandas. If you’re new to time-series resampling, Finxter’s pandas DataFrame resample() guide explains the resample("ME").last() line that turns daily prices into month-end prices.
"""How often to rebalance a 60/40 portfolio? Backtest on real fund data, Dec 1986 to Sep 2026.
Stocks: VFINX (Vanguard 500 Index, S&P 500). Bonds: VBMFX (Vanguard Total Bond Market Index).
Yahoo Finance adjusted closes (dividends reinvested, fund fees included), month-ends.
Cash rate for Sharpe: FRED TB3MS (3-month T-bill). pip install yfinance pandas"""
import pandas as pd
START, END, W = "1986-12-31", "2026-09-30", 0.60
def load():
try: # cached copies used for the article
adj = pd.read_pickle("src/raw_yf.pkl")["Adj Close"][["VFINX", "VBMFX"]]
tb = pd.read_csv("src/tb3ms.csv", parse_dates=[0], index_col=0).iloc[:, 0]
except FileNotFoundError:
import yfinance as yf
adj = yf.download(["VFINX", "VBMFX"], start="1986-12-01", auto_adjust=False, progress=False)["Adj Close"]
tb = pd.read_csv("https://fred.stlouisfed.org/graph/fredgraph.csv?id=TB3MS", parse_dates=[0], index_col=0).iloc[:, 0]
px = adj.resample("ME").last().loc[START:END]
tb.index = tb.index + pd.offsets.MonthEnd(0)
rf = (tb / 100 / 12).reindex(px.index).ffill()
return px.pct_change().dropna(), rf.iloc[1:]
def backtest(r, rule, w=W, band=0.05, start_value=10_000):
"""rule: 'never' | 'monthly' | 'quarterly' | 'annual' | 'band'. Rebalance at month-end after returns."""
s, b = start_value * w, start_value * (1 - w)
vals, wts, n_reb, turnover = [], [], 0, 0.0
for d, (rs, rb) in r.iterrows():
s *= 1 + rs; b *= 1 + rb; v = s + b; eq = s / v
due = {"never": False, "monthly": True, "quarterly": d.month in (3, 6, 9, 12),
"annual": d.month == 12, "band": abs(eq - w) > band}[rule]
if due and abs(eq - w) > 1e-12:
turnover += abs(eq - w) * v; n_reb += 1
s, b = v * w, v * (1 - w); eq = w
vals.append(v); wts.append(eq)
return pd.DataFrame({"value": vals, "equity_w": wts}, index=r.index), n_reb, turnover
def stats(df, rf, n_reb, turnover):
v = df.value; m = v.pct_change().fillna(v.iloc[0] / 10_000 - 1); yrs = len(v) / 12
ex = m - rf.values
return dict(end=v.iloc[-1], cagr=(v.iloc[-1] / 10_000) ** (1 / yrs) - 1, vol=m.std() * 12 ** 0.5,
max_dd=(v / v.cummax() - 1).min(), dd_at=(v / v.cummax() - 1).idxmin(),
sharpe=ex.mean() / m.std() * 12 ** 0.5, rebalances=n_reb,
turnover_yr=turnover / v.mean() / yrs, max_eq=df.equity_w.max(), end_eq=df.equity_w.iloc[-1])
RULES = {"never": "Never (buy & hold)", "monthly": "Monthly", "quarterly": "Quarterly",
"annual": "Once a year (Dec)", "band": "When off by 5 points"}
def run():
r, rf = load()
res = {k: backtest(r, k) for k in RULES}
return r, rf, res, {k: stats(df, rf, n, t) for k, (df, n, t) in res.items()}
def rolling(r, rf, years=10):
"""CAGR and max drawdown of each rule in every rolling `years` window (monthly starts)."""
n, rows = years * 12, []
for i in range(0, len(r) - n + 1):
sub = r.iloc[i:i + n]; row = {"start": sub.index[0]}
for k in ("never", "monthly", "annual", "band"):
v = backtest(sub, k)[0].value
row[k] = (v.iloc[-1] / 10_000) ** (1 / years) - 1; row[k + "_dd"] = (v / v.cummax() - 1).min()
rows.append(row)
return pd.DataFrame(rows).set_index("start")
def napkin_cost(turnover_yr, cost=0.002, value=100_000):
"""ASSUMPTION: each dollar traded costs `cost` (spread, fees or tax drag). Annual drag in $ on `value`."""
return turnover_yr * cost * value
if __name__ == "__main__":
r, rf, res, S = run()
print(f"60/40 VFINX/VBMFX, $10,000 on {START}, month-end data to {END} ({len(r)} months)")
print(f"{'Rule':<22}{'End value':>11}{'CAGR':>7}{'Vol':>7}{'MaxDD':>8}{'Sharpe':>7}{'Trades':>7}{'Turn/yr':>8}{'Max stock%':>11}")
for k, s in S.items():
print(f"{RULES[k]:<22}{s['end']:>11,.0f}{s['cagr']:>7.2%}{s['vol']:>7.2%}{s['max_dd']:>8.1%}{s['sharpe']:>7.2f}"
f"{s['rebalances']:>7}{s['turnover_yr']:>8.1%}{s['max_eq']:>11.1%}")
w, a = res["never"][0].equity_w, res["annual"][0].equity_w
print(f"\nBuy & hold ends at {S['never']['end_eq']:.1%} stocks; it entered the Oct 2007 peak at {w.loc['2007-10-31']:.1%} stocks.")
print(f"Buy & hold first passed 70% stocks in {w[w > .7].index[0]:%b %Y} and 80% in {w[w > .8].index[0]:%b %Y}.")
print(f"Annual rebalancing still drifted between {a.min():.1%} ({a.idxmin():%b %Y}) and {a.max():.1%} stocks between Decembers.")
print(f"All five rules hit their worst drawdown in {S['never']['dd_at']:%b %Y}.")
R = rolling(r, rf)
print(f"\n{len(R)} rolling 10-year windows (starts {R.index[0]:%b %Y} to {R.index[-1]:%b %Y}):")
for k in ("monthly", "band"):
g = R[k] - R["annual"]
print(f" {RULES[k]} minus annual CAGR: median {g.median()*100:+.2f} pts, range {g.min()*100:+.2f} to {g.max()*100:+.2f}")
g = R["never"] - R["annual"]; dd = R["never_dd"] - R["annual_dd"]
print(f" Never minus annual CAGR: median {g.median()*100:+.2f} pts; never had the deeper drawdown in {(dd < 0).mean():.0%} of windows")
print(f"\nNapkin (assumption: 0.2% cost per dollar traded, $100,000 portfolio):")
for k in ("monthly", "quarterly", "annual", "band"):
print(f" {RULES[k]:<22} {napkin_cost(S[k]['turnover_yr']):>6,.0f} $/yr")
The result: how often matters less than whether
Output of python3 rebalance.py:
60/40 VFINX/VBMFX, $10,000 on 1986-12-31, month-end data to 2026-09-30 (477 months)
Rule End value CAGR Vol MaxDD Sharpe Trades Turn/yr Max stock%
Never (buy & hold) 442,046 10.00% 11.75% -38.7% 0.61 0 0.0% 93.9%
Monthly 304,242 8.97% 9.54% -32.5% 0.63 477 9.2% 60.0%
Quarterly 313,794 9.06% 9.50% -32.0% 0.64 159 6.1% 63.6%
Once a year (Dec) 312,520 9.05% 9.53% -30.8% 0.64 39 3.3% 67.9%
When off by 5 points 325,566 9.16% 9.62% -31.9% 0.65 26 3.6% 65.0%
Buy & hold ends at 93.9% stocks; it entered the Oct 2007 peak at 78.6% stocks.
Buy & hold first passed 70% stocks in Nov 1995 and 80% in Dec 1998.
Annual rebalancing still drifted between 47.9% (Nov 2008) and 67.9% stocks between Decembers.
All five rules hit their worst drawdown in Feb 2009.
358 rolling 10-year windows (starts Jan 1987 to Oct 2016):
Monthly minus annual CAGR: median -0.11 pts, range -0.38 to +0.12
When off by 5 points minus annual CAGR: median +0.03 pts, range -0.35 to +0.31
Never minus annual CAGR: median -0.09 pts; never had the deeper drawdown in 73% of windows
Napkin (assumption: 0.2% cost per dollar traded, $100,000 portfolio):
Monthly 18 $/yr
Quarterly 12 $/yr
Once a year (Dec) 7 $/yr
When off by 5 points 7 $/yr
Rule
$10,000 became
CAGR
Volatility
Max drawdown
Sharpe
Trades
Max stock share
Never (buy & hold)
$442,046
10.00%
11.75%
−38.7%
0.61
0
93.9%
Monthly
$304,242
8.97%
9.54%
−32.5%
0.63
477
60.0%
Quarterly
$313,794
9.06%
9.50%
−32.0%
0.64
159
63.6%
Once a year (Dec)
$312,520
9.05%
9.53%
−30.8%
0.64
39
67.9%
When off by 5 points
$325,566
9.16%
9.62%
−31.9%
0.65
26
65.0%
60% VFINX / 40% VBMFX, month-end Yahoo Finance adjusted closes, Dec 31, 1986 to Sep 30, 2026. Sharpe uses FRED TB3MS. No taxes or trading costs. rebalance.py
The three calendar rules are almost identical: 8.97% to 9.06% a year, about 9.5% volatility, and Sharpe ratios of 0.63 to 0.64. Rebalancing 12 times more often bought you nothing. Monthly came out a hair behind because it held exactly 60% stocks all the time. The yearly portfolio averaged 60.6% stocks because it drifted a little between Decembers, and in a market where stocks win, that small extra share earns a bit more.
39 yearly trades matched 477 monthly ones on Sharpe and drawdown (Yahoo Finance fund data; rebalance.py).
The 5-point band came out slightly ahead, with the highest end value ($325,566), the best Sharpe (0.65) and the fewest trades (26). That edge is small and depends on the period. The steadier way to say it: a band rule gets you yearly-level risk control with even fewer trades.
This matches Vanguard’s much longer study (1926 to 2009). Its monthly-rebalanced 60/40 needed 1,008 rebalancing events and its annual one 83, with average returns of 8.5% and 8.6%. Vanguard’s conclusion was that annual or semiannual monitoring, with rebalancing at 5% thresholds, is a reasonable balance for most broadly diversified stock and bond portfolios.
The real choice: rebalance or let it drift
The never-rebalanced portfolio ended with $442,046, about $116,500 more than the best rebalanced one. So why rebalance at all?
Because by the end it wasn’t a 60/40 portfolio anymore. Stocks grow faster than bonds, so with no rebalancing the stock share keeps climbing:
Each December: buy and hold drifts toward 94% stocks while the band and yearly rules stay near 60%.
It passed 70% stocks in November 1995 and 80% in December 1998. It went into the October 2007 market peak at 78.6% stocks, so the 2008 crash hit far harder than a 60/40 should. The worst drawdown was −38.7%, against −30.8% for the yearly rebalancer. Volatility was 11.75% vs 9.53%. On a risk-adjusted basis buy and hold came last (Sharpe 0.61).
Vanguard found the same pattern over 1926 to 2009. Its never-rebalanced 60/40 drifted to a maximum of about 99% stocks and earned a higher average return (9.1% vs 8.5% for monthly), with more risk (14.4% vs 12.1% standard deviation). The extra return is mostly payment for holding more stock than you signed up for.
Rule: rebalancing isn’t about squeezing out return; it keeps the portfolio’s risk at the level you chose. If you never rebalance, your 60/40 quietly turns into a stock portfolio, right before a crash shows you the difference.
Rolling 10-year windows: no lucky period
One 40-year run could just be luck, so the script also tests every rolling 10-year window: 358 of them, starting each month from January 1987 to October 2016.
Every rolling 10-year window: monthly vs yearly is a rounding error; never rebalancing is a swing (rebalance.py).
Monthly minus yearly: median −0.11 percentage points a year, never more than +0.12 better. Monthly almost never wins, and when it does, it wins by a rounding error.
5-point band minus yearly: median +0.03 points, range −0.35 to +0.31. That’s a coin flip.
Never minus yearly: median −0.09 points, with swings from about −0.6 to +1.6. Buy and hold also had the deeper drawdown in 73% of the windows.
So over a typical decade, never rebalancing didn’t even reliably earn more. It took more risk for an uncertain reward. The 40-year advantage comes from the drift compounding over a very long bull run in stocks.
What about trading costs?
At these frequencies, the cost of trades is small. Annual rebalancing traded about 3.3% of the portfolio per year, and monthly about 9.2%. The napkin math below rests on a labeled assumption that each traded dollar costs 0.2% (spread, commissions or tax drag). Then a $100,000 portfolio pays about $18 a year for monthly rebalancing and $7 for yearly.
Taxes are the cost that actually matters. In a taxable account, every rebalance sells winners and can trigger capital gains, which is why Vanguard says annual rebalancing is likely preferred when taxes or substantial costs are involved. Three practical fixes:
Rebalance with cash flows. Put new contributions, dividends and interest into whichever side is underweight. Vanguard specifically recommends this to cut costs.
Rebalance inside tax-advantaged accounts (401(k), IRA or your country’s equivalent) where selling doesn’t create a tax bill.
Use the 5-point band so you only trade when the drift is big enough to matter.
A simple rebalancing routine
Pick a date you’ll remember, for example your birthday or the first weekend of January.
Check your current weights. If you track holdings by hand, PortfolioTracker24 is a free browser tracker that keeps your data on your machine (no account, no broker connection), shows allocation by value, and exports Excel backups.
If stocks are more than 5 points off target, rebalance, starting with new cash and then tax-advantaged accounts.
If they’re inside the band, do nothing. That’s the rule working, not you being lazy.
Once you’ve run this on your own funds, you have original analysis: real numbers, charts and a clear rule. That’s good material for a short guide, a newsletter lead magnet, or a small ebook. ImagineYourBook is an AI book-writing tool that turns a prompt or your notes into a complete ~10,000-word draft with its own cover, with Word and EPUB export on paid plans. Paste in your results table and rules, then edit the draft so every number matches your script. If you plan to sell it on Kindle, run the price through Finxter’s KDP royalty calculator in Python first.
Question
What 40 years of data says
Do this
Monthly or quarterly?
Same Sharpe as yearly (0.63-0.64), 4-12x the trades
Check monthly or yearly, trade only past ±5 points
Never?
Higher return, but 93.9% stocks and −38.7% drawdown
Only if you really want a stock-heavy portfolio
Taxable account?
Selling winners can trigger capital gains
Rebalance with new cash and inside tax-advantaged accounts
Sources: rebalance.py results; Vanguard, Best practices for portfolio rebalancing.
Limitations
One stock fund, one bond fund, US only. International stocks, small caps or other bond types will drift at different speeds.
Month-end data. A band rule checked daily would trade at slightly different points. Vanguard notes that threshold-only rules need daily monitoring.
No taxes or trading costs in the backtest itself; the cost section above is assumption-based napkin math.
The past isn’t a forecast. Stocks returned much more than bonds over 1987 to 2026. In a period where bonds keep up, the drift (and the gap from never rebalancing) would be smaller. Not investment advice.
The takeaway
How often to rebalance a portfolio: once a year, or whenever an asset class drifts more than 5 points off target. Monthly and quarterly rebalancing only added trades in 40 years of real fund data. Never rebalancing turned a 60/40 into a 94/6 portfolio and gave it the deepest crash. Set one date a year and use new cash first. And if your backtest turns into a guide worth sharing, ImagineYourBook can turn your notes into a first draft: the $0 trial includes one 10,000-word book, requires a card, and becomes $29.99 a month unless you cancel before day 8.
Glock 17 Gen 6 owners who wanted a compensated, threadless barrel have been waiting while Radian started with other models. The Afterburner + Ramjet system launched first for the G19 and G45, with a G17 variant to follow, and that G17 version is now official. It comes in three colorways, including black, grey (below) and bronze (above).
The reason the Gen 6 needed its own version is in the details. The system is machined to Gen 6 specifications, addressing subtle but critical differences in barrel geometry that prevent safe cross-compatibility with Gen 5 models, so a Gen 5 setup is not a substitute.
The system has two parts. The AFTERBURNER compensator is machined from hardened stainless steel, and the RAMJET barrel from hardened 416R stainless steel. Both parts carry a Radianite coating for low friction and corrosion resistance. The barrel is a gas-sealing chamber design, and the aim is recoil reduction and match-grade accuracy in a concealed carry package.
The threadless design is the feature that sets the system apart from a typical threaded compensator. It allows effortless installation and removal for cleaning, which makes routine maintenance easier for anyone who strips their pistol often.
Size matters for carry, and anything that goes into a holster, and Radian kept it small. On a Glock 17, the combo adds only .47 inch to the overall slide length, which allows compatibility with all G34 holsters. That means most owners should not have to replace their holster just to use it. The system is described as legal for sale in all 50 states.
Is a threadless design a reason to choose the Radian over a threaded compensator?
If this post has you ready to hit the range, head over to gunranges.com – a free directory to help you find shooting ranges near you, wherever you are in the United States.
J.K. Rowling published the beloved children’s book series Harry Potter between 1997 and 2007. Then she made herself public enemy number one in 2020 when she wrote an essay condemning the transgender movement, particularly where it takes away the rights and safety of women and girls. It was a brave stand in the face of COVID-era craziness. Rowling was among the few rich and famous voices who pointed out what a farce this ideology made of women’s rights. We were at the height of cancel culture, and the trans agenda seemed to be infiltrating every institution.
Six years later, Rowling has penned a new essay in which she revisits her own thoughts and brings some more observations to the public’s attention.
Her convictions on transgenderism’s interference in women’s rights remain consistent. Rowling believes that if one group’s ostensible rights supersede another group’s actual rights, that’s neither just nor liberating. It’s authoritarian and turns the “oppressed” into the oppressor. As National Review contributor Raleigh Adams articulates, this is exactly what Brazilian political theorist Paulo Freire predicted — because at the end of the day, the lust for power eclipses the crusade for equal rights under the law.
Rowling addresses the current climate of transgender activism:
For a decade or more, trans activists and acolytes in positions of influence have insisted and behaved as though women’s rights were out-dated and even meaningless. They created a climate of fear and intimidation whereby a single misplaced pronoun could see a woman disciplined.
That climate persists. Gender ideology remains so entrenched among swathes of the management and political class that women are still having to fight for their rights in law. These have included nurses protesting at having to change their clothes in front of heterosexual, cross-dressing men; rape survivors stigmatised for protesting at the presence of males in their support groups or crisis centres; women investigated and persecuted at work for even questioning the approved gender mantras; parents whose daughters are having to share primary school toilets with boys as they approach puberty; and incarcerated women, already with a high likelihood of sexual violence in their pasts, forced to share intimate facilities with trans identified males.
Rowling still receives death threats, and the activists love to target anything having to do with her, even if it’s a gala performance.
While the climate hasn’t changed, the people pushing it have. Rowling is absolutely right: The transgenderism crusade of luxury beliefs and elitism is cultivated in the minds of philosophy and gender studies professors and spoon-fed in college classrooms. This is not a grassroots movement in which a group is fighting against government institutions for equal rights. This movement was manufactured by those with no accountability to the larger community, and instead of forming from the bottom up, it infiltrates from the top down with draconian precision.
Rowling also points out that the transgenderism belief system is mainly upheld by people who don’t have to deal with the consequences of imposing their beliefs on the culture. She writes:
Trans activism doesn’t impact all women negatively. In fact, the more privileged you are, the less it tends to affect you, and the more it might benefit you. Aside from an avalanche of death, rape and torture threats, it hasn’t noticeably affected my life. Nor does it hurt wealthy women who repost the vogueish mantras while sitting in dressing rooms guarded by private security guards. It doesn’t hurt women in the upper echelons of media, publishing and academia, who don’t need a changing room at work and who feel a snobbish disdain for working class women who don’t want to strip off in front of heterosexual males who’ve ‘identified’ into the women’s changing room. Trans activism doesn’t hurt headline-hungry female politicians whose attitude to women’s rights is ‘whatever is good for me personally’. On the contrary, trans activism has been good for those women. It’s fashionable, and keeps them popular with their progressive tribe, and costs them, personally, absolutely nothing.
Rowling is British, but this is exactly the case here in the States and exactly what I have been pointing out with individuals like former U.S. women’s soccer star Megan Rapinoe: Their beliefs cost them nothing, while the rug is being pulled out from under those who are trying to succeed just like the activists did.
While promoting transgender ideology to the detriment of all women is certainly bad enough, what’s worse is that it has become ensconced in some government institutions. This is especially true for Rowling.
A native of Edinburgh, Scotland, Rowling who has used her money and influence to start a women’s-only rape crisis center called Biera’s House (the only other center of its kind in the city is run by a gender-confused man). She also started J.K. Rowling’s Women’s Fund, which helps women take action against companies and government organizations that have taken away their rights.
Through her fund, Rowling has noticed a startling trend: After the 2025 Supreme Court ruling in Scotland that clarified that “woman” refers to the immutable biological fact of sex, most companies ceased their top-down enforcement of the trans agenda in the workplace. However, government workplaces are entrenched, and because they have access to taxpayer money, Rowling says “they continue to fight the idea that women have, or should have, the rights the Supreme Court upheld.” In other words, the battle is not over.
In America, there is a sense that the battle has been won because of the results of the 2024 election. While transgenderism isn’t currently the main front in the culture war, it’s part of the Left’s omnicause. “Free Palestine,” abortion, socialism, defund the police, abolish ICE, and gender ideology are all being pushed simultaneously.
The silver lining is that each of these issues contradicts one other; the only thing holding them together is hatred of the West. Eventually, the coalition will eat itself. In the meantime, it would be best for our country if that implosion occurs after the Left’s omnicause gets defeated again at the ballot box on November 3.
Plex shipped a security update in August that closed a chain of bugs letting anyone who can reach an exposed Plex Media Server run code on it. In one detailed public account, a hobbyist who later stood up a vulnerable server as a honeypot says attackers used that same chain on Saturday to drop a crypto miner and residential proxy software that lets other people route their traffic through a victim’s home internet connection, essentially using it as a VPN without the victim’s knowledge.
The bugs were originally found by a security researcher who goes by ZM online. In a technical write-up published Thursday, ZM described an unauthenticated remote code execution chain he calls Plex2Shell. In short, an attacker with no real login can trick a server into handing over its owner’s admin token, then plant a malicious file that runs the next time Plex transcodes a video. The chain strings together four vulnerabilities tracked as CVE-2026-96652, CVE-2026-96654, CVE-2026-96655, and CVE-2026-96656. Affected servers are those running versions before 1.43.3.10861.
ZM reported the issue to Plex on July 27th. Plex acknowledged it and paid a $500 bug bounty on August 4th, the same day the fixed build became publicly available to download. On September 1st, Plex posted a public advisory and, according to BleepingComputer, emailed users still on older versions. “We recommend all server owners and Desktop users update to the latest version as soon as possible,” Plex wrote. The company also said it had requested CVEs, which were assigned on September 23.
“Unauthenticated RCE chain in Plex,” ZM posted alongside the writeup and proof-of-concept code. “Patch your servers!” The $500 payout for such a critical exploit was immediately criticized, with multiple X users asking whether ZM had forgotten some zeros on the payout amount.
At the same time, an anonymous blogger who runs boredhackerblog had been waiting for those details to be revealed. “As an infosec person and a Plex user, I was really curious about what this vuln was and if I could see in-the-wild exploitation,” he wrote. He spun up a honeypot on an old Plex build. “As of this morning (Oct 17th), I finally saw in the wild exploitation using exploit code from the authors github,” they reported Saturday. The first payload was a crypto miner. Later the same day, they also saw a tool called myawesomeproxy, which prints a notice that running it means agreeing to “the potential external use of your internet traffic.” In other words, the attackers were turning the box into a residential proxy, the kind that other hackers rent to look like ordinary traffic while also masking their own IP addresses.
Kraken Chief Security Officer Nick Percoco cited the honeypot attack in an X post on Sunday. “If you run Plex at home, update it today,” he wrote, adding, “The payload sits in Plex’s Codecs folder and runs again every time something transcodes, so killing the miner doesn’t fix it.”
Plenty of people still have not patched their Plex servers. Shadowserver data on hosts tagged vulnerable-plex shows nearly 49,000 still exposed as of Saturday, including about 25,100 in North America and about 17,600 in Europe.
Shadowserver
Not the First Plex Exploit
Plex has had its fair share of security incidents over the years. In September 2025, Plex told users to reset their passwords after an unauthorized party accessed emails, usernames, and hashed passwords. A month earlier, after a separate bug bounty report, Plex emailed users and told them to update to version 1.42.1. Weeks later, with many servers still vulnerable, it went further. “As a follow-up to the original report and in an abundance of caution due to a population of servers still on an affected Plex Media Server version (versions 1.41.7.x to 1.42.0.x), we have made adjustments for access to affected servers,” Plex wrote. “Specifically, other users to which the server owner has granted access to the server will not be able to connect to it while the server is running an affected version.” That flaw is now tracked as CVE-2025-34158.
Plex did not apply a similar access restriction, or otherwise require the update, after Plex2Shell became public. Its September 1st notice recommended an update and pointed Windows and Mac users at an optional automatic-update setting. That setting defaults to “Ask me,” not to auto-install, and Plex’s own support docs say it only applies to Windows and macOS. Linux, Docker, and NAS installs depend on package managers, container restarts, or manual downloads. Plex’s terms of service say the company can require an update “as a means to protect the Plex Solution from unauthorized use, content, or data,” and that declining one may cut off access. As of Sunday afternoon Eastern time, Plex had also not issued any new public statement about the exploit or the attacks that followed it.
A previous LastPass hack also shows that the stakes involved with this kind of vulnerability in Plex server software can be quite high. In 2023, LastPass said attackers got into a senior DevOps engineer’s home computer “by targeting the DevOps engineer’s home computer and exploiting a vulnerable third-party media software package, which enabled remote code execution capability and allowed the threat actor to implant keylogger malware,” according to Ars Technica. Ars reported that a person briefed on a private LastPass report identified the package as Plex; however, LastPass and Plex have not publicly confirmed that link in matching detail. Either way, it is the nightmare version of what a vulnerable residential Plex server can become when a remote code execution exploit is discovered.
pg_stat_monitor started as a fork of pg_stat_statements, built to be a more advanced replacement for it. It provides more detailed information about query execution and groups it in configurable time buckets, so you can look at what happened in a particular time window. It also powers the Query Analytics feature in Percona Monitoring and Management (PMM). However, over time other work took priority at Percona, and the extension stopped getting the attention it needed. The original developers moved on to other things, and the reasoning behind several design decisions went with them. For a couple of years it stayed in maintenance mode, we were fixing reported bugs, and never stepped back to look at deep architectural issues. All that led to a degraded user experience and stability issues.
2.4.0 is our attempt to turn that around. It is the biggest release in years: about 250 commits since 2.3.2. We restructured the code and fixed bugs across the whole extension, and the C code got about 20% smaller. Among the fixes are several buffer overflows, a use-after-free, a lock ordering problem and a few statistics that were simply wrong. We also backported the pg_stat_statements regression tests. The most important fixes, though, are about memory management, and that is what this post is about. For everything else, see the release notes.
The symptoms
Well, memory leaked under certain workloads. The end was always the same: the process eventually got killed by the OOM killer.
Not every workload was affected, it depended on how it was run. Queries sent over the simple protocol were fine. Named prepared statements leaked memory on every execution. Statements running through SPI inside a background worker leaked the same way. In both cases nothing reclaimed the memory, so growth stopped only when the session or the worker did.
Another problematic case is when statements produce warnings. On PostgreSQL 18 and earlier, each warning caused a small memory leak, which could accumulate over time if many warnings were generated.
How PostgreSQL manages memory
Server code does not call malloc() and free() directly. Instead it uses memory contexts, also known as memory arenas outside PostgreSQL. Every allocation happens within a context, and freeing the context releases all allocated memory at once. Contexts form a tree structure, so freeing a parent context frees every child context as well. This is what lets PostgreSQL manage memory efficiently and survive an error thrown in the middle of a query.
So when you write an extension, you should keep in mind which context your allocations belong to and when that context will be freed.
Four contexts matter for our topic:
TopMemoryContext is the root context for the backend. It lives as long as the backend does and is only destroyed when the backend exits.
TopTransactionContext is reset at every commit and every abort, which deletes all of its child contexts.
MessageContext is reset after each client protocol message.
ErrorContext is reserved for error reporting. On PostgreSQL 18 and earlier, it is only reset during error recovery.
An extension can create its own context or use one of the existing ones. Choosing a context with the wrong lifetime causes problems: your data either vanishes too early or stays around longer than needed. The latter looks exactly like a leak, even though every byte is still accounted for.
How pg_stat_monitor managed its memory
The original design
pg_stat_monitor collects per-statement statistics in backend-local memory first, and dumps them into the shared time buckets when the statement finishes and we have the full set of data. That local memory lived in a context called PgsmMemoryContext, and it was created as a child of TopMemoryContext. TopMemoryContext lives as long as the backend does. So instead of relying on context lifetime, pg_stat_monitor used a persistent context, and for cleanup it used a reset callback assigned to another context: MessageContext. MessageContext is reset after every client protocol message, and a reset invokes the callback, which cleaned out PgsmMemoryContext without destroying it.
One detail matters for everything below. A reset callback in PostgreSQL is one-shot: it is popped off the context’s callback list before it is called, so it fires once and is gone. To keep the cleanup working, pg_stat_monitor had to register a fresh callback for every message. It did this from the post parse analyze hook, one of the first steps in the statement lifecycle. So callback setup depends not on the statement finishing, but on the next message going through parse analysis. This design has two weak points, and we hit both of them.
The callback is not registered on every execution path
Background workers have no MessageContext. So a statement running through SPI inside a background worker never arms the reset callback at all. Nothing resets PgsmMemoryContext, and because its parent is TopMemoryContext, nothing else is going to. It keeps growing for the life of the worker.
In the extended query protocol, the client sends a named prepared statement as separate messages: Parse once, then Bind and Execute for every run. Parse analysis happens only during Parse, so that is the only message that arms the callback. When MessageContext is reset after Parse, the callback fires and nothing re-arms it. MessageContext is still reset after every Bind and Execute, but no callback is attached to it anymore, so PgsmMemoryContext keeps growing for the life of the session. This is the pattern database drivers use for prepared statements. Note that the SQL-level PREPARE and EXECUTE commands are not affected, because each EXECUTE statement goes through parse analysis itself and re-arms the callback.
The error path allocated into a context nobody resets
pg_stat_monitor records the warnings and errors a statement produces, so it hooks into the code PostgreSQL uses to emit log messages. That hook runs in the middle of error reporting, and error reporting has a memory context of its own, ErrorContext. PostgreSQL keeps that one reserved so it always has somewhere to build a message, even when the backend is short on memory. It is cleaned out when the server unwinds from an error. A WARNING does not unwind anything: the statement simply carries on. PostgreSQL 19 added ErrorContext resets after reporting a warning, so this leak only affects PostgreSQL 18 and earlier.
Our hook copied the query text wherever it happened to be running, so that copy landed in ErrorContext instead of in pg_stat_monitor’s own context. Nothing was ever going to reclaim it. The entry for the statement itself went to PgsmMemoryContext, so it stayed there until the next MessageContext reset cleaned it up, if that ever happened.
Both problems come from the same place. We inherited the design from pg_stat_statements, which we forked years ago, and we never revisited it as pg_stat_monitor grew code paths that upstream does not have. The test suite did not help either. We had nothing that ran a background worker in a loop, nothing that held a session open across thousands of Bind and Execute cycles, and nothing that measured backend memory at all. None of this is exotic.
What we changed
Both problems have the same shape. The memory sat in a context that never dies, and the cleanup depended on an event that does not always happen. Rather than trying to make the cleanup callback fire on every path, we changed where the memory lives.
Every path that can execute a statement runs inside a transaction. Background worker SPI, named prepared statements, ordinary queries. A background worker may not have a MessageContext, but it always has a transaction.
The change is:
PgsmMemoryContext is no longer a child of TopMemoryContext. It is created as a child of TopTransactionContext, so it is automatically deleted at every commit and every abort. There is no callback to register and nothing to re-arm, so there is no execution path left that can skip the cleanup.
There is a special handling of subtransactions, but in any case the memory is tied to the top-level transaction and will be cleaned up when that transaction ends.
The error and utility statement paths no longer allocate an entry at all. They use a local variable that goes away by itself when the function returns, which removes the ErrorContext allocations completely.
This covers all three cases from the beginning of the post. A background worker always runs its statements inside a transaction, so its memory is now freed when that transaction ends, the same as in a regular backend. Named prepared statements no longer depend on parse analysis to clean up after them, every Execute is followed by a commit that frees the memory. Warnings no longer allocate anything that outlives the hook.
What’s next
2.4.0 was about stabilizing the code base we already have. We reworked memory management, removed code we no longer need, and built the test coverage that should have been there from the start. We did not change how the extension is structured. That is the plan for the next major release: we want to revisit the architecture of pg_stat_monitor, starting with how it uses shared memory and how it behaves when that memory fills up. ∎