The fourth WordPress plugin costs 4x the obvious estimate

  • Sahil Shrestha
  • 17 min read

Three WordPress plugins that own a write path cost about 38 hours of maintenance a year. Four cost 61. Half a day a month is 48, so the fourth plugin is where the site stops paying for itself — not the thousandth visitor, not the licence renewal, and not the page count.

Every figure above comes out of a model I build below from two published release cadences. Nothing in it was measured on anybody’s site. Fifteen minutes a check, half a day a month and a $59 licence are numbers I chose, and I say so where each one turns up. Maintenance rises with the square of the plugin count, and the straight-line version is what ends up in a budget.

Is WordPress cheaper than a custom build?

Yes, up to three plugins that own a write path, and no further. The crossover computes to 3.45 against a maintenance budget of half a day a month, so three fit inside it and four sit outside. Traffic doesn’t move that number. Neither does page count. Licence spend barely moves it either: at four plugins the licences are 13.3% of the annual bill and the hours are the rest.

Start from the budget, because a threshold with no tolerance above it is a number somebody liked the look of. The tolerance a small WordPress site actually gets is one person, one morning a month, nobody on call. Half a day a month, twelve times over, is 4 × 12 = 48 hours. Three write-path plugins need 38.15 hours a year against that and four need 61.37, which is 61.37 / 48 = 1.28 times what there is.

Below four plugins that own a write path, WordPress is the cheaper build and I wouldn’t argue with anyone who picks it; at four the regression work reaches 61.37 hours a year against a 48-hour budget and the argument is over.

Counting the plugins that own a write path

A write-path plugin stores state in wp_postmeta on a visitor’s request rather than only rendering, and it is the only kind worth counting. Form handlers, bookings, orders, licence checks — anything that turns a stranger’s POST into a row. A gallery plugin that prints an album isn’t one, and nor is a cache that writes files but never post meta.

The line sits at who triggered the write. An SEO plugin writing its meta when an editor hits publish writes under supervision, and the regression shows up as a wrong-looking preview inside ten seconds. A booking plugin writes when nobody is watching, and the regression shows up three weeks later as a slot sold twice.

Twelve plugins with two write paths is a two-plugin site by this measure. Four that all write cost what four costs.

Gravity Forms is the clean case: a plugin whose whole job is to own a write path. Its own pricing page lists Basic at $59 a year, Pro at $159 and Elite at $259. I price every write-path plugin at that $59 below. It flatters my own argument — a booking engine doesn’t sell at a form plugin’s entry tier, and a dearer licence pushes the licence share past the 13.3% I get at four plugins. Hold on to the $59.

Every meta_query clause is its own wp_postmeta join

Under the default relation of AND, two clauses on two different meta keys never share a table alias, so each one adds its own INNER JOIN against wp_postmeta. The reason is the schema wp_get_db_schema() creates on every install.

sql

CREATE TABLE wp_postmeta (
  meta_id bigint(20) unsigned NOT NULL auto_increment,
  post_id bigint(20) unsigned NOT NULL default '0',
  meta_key varchar(255) default NULL,
  meta_value longtext,
  PRIMARY KEY  (meta_id),
  KEY post_id (post_id),
  KEY meta_key (meta_key(191))
)

Four columns, two non-primary keys. No index on meta_value, no composite index on (post_id, meta_key), and the meta_key index covers 191 of 255 characters. The 191 is borrowed arithmetic — section 17.21 of the MySQL 8.4 Reference Manual holds the figure it was borrowed from:

The index key prefix length limit is 767 bytes for InnoDB tables that use the REDUNDANT or COMPACT row format. For example, you might hit this limit with a column prefix index of more than 191 characters on a TEXT or VARCHAR column, assuming a utf8mb4 character set and the maximum of 4 bytes for each character.

767 / 4 = 191.75, floored to 191, and WordPress hard-codes that division as $max_index_length. The bullet above that one puts the limit at 3072 bytes for DYNAMIC row format, InnoDB’s default, so 767 is not what a current server enforces. Core’s own comment above $max_index_length is the better citation, and it dates the decision: an index “used to have room for floor(767/3) = 255 characters, now only has room for floor(767/4) = 191 characters” once 4.2 moved to utf8mb4. I had the meta_key index down as covering the whole column until I opened schema.php to check.

Now the part that couples plugins. WP_Meta_Query::get_sql_for_clause() gives the first clause the meta table’s own name as its alias and numbers every clause after it: $alias = $i ? 'mt' . $i : $this->meta_table;.

There is no mt0.

I reconstructed the query below from that alias line, not off a running site; print $wp_query->request to check it.

sql

SELECT wp_posts.ID FROM wp_posts
  INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
  INNER JOIN wp_postmeta AS mt1 ON ( wp_posts.ID = mt1.post_id )
  INNER JOIN wp_postmeta AS mt2 ON ( wp_posts.ID = mt2.post_id )
WHERE wp_postmeta.meta_key = '_booking_state'
  AND mt1.meta_key = '_licence_tier'
  AND mt2.meta_key = '_form_entry_id'

With one row per post per key, the extra legs resolve through KEY post_id and row cost stays roughly linear in clause count, so anyone claiming four meta clauses multiply rows exponentially is wrong. Repeated keys are the default, though. The fourth argument to add_post_meta() is $unique and its reference page gives the default as false, so two rows on one key against three on another give six rows per post. I have not run EXPLAIN on any of this, so read your own plan rather than mine.

The duller problem is the coupling. One clause can change the join type of all the others, as WP_Meta_Query::get_sql() says above the three lines that do it:

If any JOINs are LEFT JOINs (as in the case of NOT EXISTS), then all JOINs should be LEFT. Otherwise posts with no metadata will be excluded from results.

One NOT EXISTS clause rewrites every INNER JOIN the meta query contributed into a LEFT JOIN, because the str_replace runs over the meta query’s own join string and nothing else. The plugin that added it is right to want that. The other two never asked, and get a result set they were not written against. Deeper again, at line 821 of an 890-line file, the docblock on find_compatible_table_alias() says alias sharing “only applies to ‘IN’ clauses that are connected by the relation ‘OR’”, while the code below it shares for =, IN, BETWEEN, LIKE, REGEXP, RLIKE, >, >=, < and <=. Ten operators, one of them named in the docblock, nine not.

How many hours a year four WordPress plugins cost

Four write-path plugins cost 61.37 hours a year of regression work, against a half-day-a-month budget of 48. The model has one time constant and two arrival rates. Every plugin ships r releases a year; each costs t to check on its own plugin and t again on each of the other P−1 write paths. Core ships C releases a year, each one t per write path.

The table gives the four inputs with their units and where each number comes from.

Input Value Unit Where it comes from
r 10.50 releases per plugin per year derived from Contact Form 7’s version list
C 19.37 core releases per year derived from the WordPress Versions page
t 0.25 hours per check chosen, not measured
B 48 hours a year of budget 4 hours a month × 12, chosen

Contact Form 7’s entry on the plugin information API holds 202 plain version numbers, 1.1 through 6.2.3, with an “added” date of 2007-08-02. To 29 October 2026 that is 7,028 days: 7,028 / 365.25 = 19.24 years, so 202 / 19.24 = 10.50 releases a year. Core is steadier. The WordPress Versions page puts thirteen releases on the upgrade path of a site that always runs the newest version, in the 245 days from 6.9.1 on 3 February 2026 to 7.1.3 on 6 October, across the 6.9, 7.0 and 7.1 branches, so 13 / 245 × 365 = 19.37 a year. No single branch carries thirteen in that window — the 7.1 branch carries four. Version 7.1.4 shipped on 28 October and I left the division at 13 over 245.

Multiply out and H(P) = 2.625·P² + 4.8425·P hours a year: 10.50 × 0.25 = 2.625 is the square term, 19.37 × 0.25 = 4.8425 the linear one. The table below prices it from one to six write-path plugins in hours, in those hours at the firm’s published rate of $4,000 / 160 = $25, and in licences at the chosen $59.

Write-path plugins Hours a year Hours at $25 Licences Total a year
1 7.47 $187 $59 $246
2 20.19 $505 $118 $623
3 38.15 $954 $177 $1,131
4 61.37 $1,534 $236 $1,770
5 89.84 $2,246 $295 $2,541
6 123.56 $3,089 $354 $3,443

Solving 2.625·P² + 4.8425·P = 48 gives P = 3.45. Turn the budget round and it says the same thing in minutes. Four plugins put 4 × (10.50 × 4 + 19.37) = 245.48 checks in a year, and 48 hours is 2,880 minutes, so each check has to land inside 2,880 / 245.48 = 11.73 minutes. Three plugins put 3 × (10.50 × 3 + 19.37) = 152.61 checks in the year, and allow 2,880 / 152.61 = 18.87 minutes each. Fifteen minutes falls between them, and that is the whole threshold: three write-path plugins give you more time per check than you need, four give you less. The minutes route never uses t until that last comparison, which is why it tests the model rather than the budget.

The gap lands in units a person can feel. 61.37 hours a year / 12 = 5.11 hours a month against a budget of 48 / 12 = 4. Over 365 / 7 = 52.14 weeks that is 61.37 / 52.14 = 1.18 hours, or 70.6 minutes, against 48 / 52.14 = 0.92 hours, or 55.2 minutes. The shortfall is a quarter of an hour a week, forever. Which brings back the $59: at four plugins the licences are 4 × $59 = $236 of a $1,770 bill, and 236 / 1,770 = 13.3%. People buy plugins on the licence price and pay for them in hours.

Why the obvious maintenance estimate is out by 4x

The obvious estimate counts one check per update event and gives 15.34 hours a year at four plugins, a quarter of what the model gives. Obvious(P) = 10.50 × P × 0.25 + 19.37 × 0.25, or 2.625·P + 4.8425, so at four plugins it is 2.625 × 4 + 4.8425 = 15.3425 hours against 61.37, and 61.37 / 15.3425 = 4.00.

The ratio is clean for a reason. With one t for all three checks, H(P) = P × (2.625·P + 4.8425) = P × Obvious(P), so the model is exactly P times the obvious figure at every P. At six plugins, six times. The identity breaks the moment the three constants differ. But the direction survives whatever constants you pick.

The hidden factor is that any plugin’s update can change what lands in wp_postmeta, so the regression surface for one update event is P write paths, not one. The straight-line estimate prices the plugin you updated. The bill arrives for the plugins you didn’t. And WordPress’s own documentation names what comes next, eight sections down its page on planning, submitting and maintaining plugins:

The best plugins are the ones that keep iterating over time, pushing small changes along the way. Don’t let your hard work go stale by waiting too long to update. Keep in mind, constant upgrades can cause ‘Update Fatigue’ and users will stop upgrading. Keeping a balance between too few updates and too many updates is important.

Update fatigue is the handbook’s own word for a site owner who stopped applying the cadence the same handbook asks for.

WordPress plugins do not update themselves by default

Core minor releases install themselves and plugins don’t, unless each one is switched on by hand or the WordPress.org API marks it a special case. The Advanced Administration Handbook’s page on upgrading WordPress says new installations from 5.6 onward have “automatic updates enabled for both minor and major core releases by default, unless WordPress detects a version control checkout”, while “Existing installations keep the previous behavior”. A site that started on 5.4 and has been updated ever since is still minor-only, which is most of them. The same page adds that “In special cases, plugins and themes may be automatically updated as well”, and the Automatic Updates column under Plugins, Installed Plugins tells you which of yours those are.

So the events queue up and wait for a person. At four write-path plugins they arrive at 10.50 × 4 + 19.37 = 61.37 a year, one every 365 / 61.37 = 5.95 days. Core alone is 365 / 19.37 = every 18.84 days, and a single plugin 365 / 10.50 = every 34.76 days. The 61.37 is the hours figure again, an accident of t × P = 1. A release a month is the interval that feels manageable, and the one people estimate from.

Each plugin is a separate CVE feed too. The CVE Program record for CVE-2026-19089, an unrestricted file upload in Product Input Fields for WooCommerce affecting 2.0.0 up to but not including 2.0.2, carries a 9.8 critical score in CISA’s enrichment container. WPScan, which assigned the CVE, published no score, and NVD’s own assessment is absent, so “NVD rates it 9.8” is a sentence nobody should write.

The other end of the cadence is a plugin that stops shipping. Asked whether a new version is needed every time the code changes, the Plugin Developer FAQ answers “Yes. Otherwise no one gets updated.” Its entry on why a plugin gets closed says the directory may close one that “has never been used within 6 months (i.e. no code has been pushed to SVN)” — never used, not gone quiet, which is why stable plugins untouched for years stay open.

When a custom build is a waste of money, and when it isn’t

At two write-path plugins a custom build is a waste of money and we wouldn’t sell one. Two plugins cost 20.19 hours a year and $623 all in. The firm’s software development entry price is from $3,000 and the low end of a custom web platform is $25,000, so the gap is 25,000 − 3,000 = $22,000, and at $623 a year that is 22,000 / 623 = 35.3 years, with the custom build’s own maintenance set at zero, which it never is.

The honest case for building anyway is real, and at two plugins I’d still make it in one situation. One deploy path, one test suite, one dependency tree on your own calendar, and nobody else’s code holding a key to your database. The last clause is the condition under which a build wins at any plugin count: a write path that must not be touched by an update you did not write. A payment capture. A licence issuer. A booking that could double-sell a slot. The question becomes whether an unreviewed release can reach the row — the coupling argument one layer up from most monolith splits fail at the database, and the write-path argument write payments code as if every request happens twice makes about money.

A custom build’s cost in those same units is its own framework and dependency upgrades, which don’t vanish. They move onto your calendar instead of somebody else’s.

Now derate the input the threshold rests on. P* solves t × (10.50·P² + 19.37·P) = 48, so only the ratio 48 / t decides it. Fifteen minutes a check buys 48 / 0.25 = 192 checks a year and a crossover of 3.45; seven and a half minutes buys 384 and 5.20; under four minutes buys 768 and 7.68, the eighth plugin. So the fourth plugin is not a property of WordPress. It is a property of fifteen minutes, and a team that genuinely clears a cross-check in five should carry six.

The 4x in the headline survives all of that, because H(P) / Obvious(P) = P is an identity and does not care what t is. Double the release rate to 21 a year and the crossover is 2.60, the third plugin. Halve it to 5.25 and it is 4.48, the fifth. Count core’s thirteen releases as the twelve intervals they actually span and C falls to 12 / 245 × 365 = 17.88, which puts the crossover at 3.51. Still the fourth.

One caveat on r, which carries the square term. The 10.50 is a single plugin’s version list averaged over 19.24 years and then pointed at next year, so Contact Form 7’s 2007 cadence is inside it. I generalised it across plugins as well, and that is the weaker half: only Contact Form 7’s list runs back to its first release — WooCommerce’s starts at 3.0.0 and Yoast SEO’s at 18.0, both pruned, so the same division against their dates is simply wrong. I’d read a plugin’s own changelog before betting a budget on 10.50.

Then count the floors, the check nobody runs until a deploy breaks. Contact Form 7 6.2.3 declares requires_php 8.3, while WooCommerce 11.2.1 and Yoast SEO 28.8 both declare 7.4. Four plugins sharing one PHP process might disagree about the minimum, the highest floor wins for everybody, and that upgrade is in nobody’s 48 hours.

Tell us what’s broken.

Twenty minutes, no deck. We’ll tell you straight whether this is work we should be doing, and if it isn’t, we usually know who should.

We come back within one business day.