Ruby on Rails vs WordPress
Somebody asking whether to build a site in Ruby on Rails or WordPress is usually being told that the question is confused, because one is a framework and the other is a finished application. That answer is true and useless. The decision in front of them is real: there is a thing to build, both stacks will produce something that loads in a browser, and the wrong choice costs months. So both were installed on one laptop, given the same data in the same database, and asked the same questions.
Conditions, because a number without them is not checkable. Apple M2 Max (Mac14,5), 12 cores, macOS
26.5.1, arm64-darwin25. WordPress 7.1.2, from the $wp_version line in wp-includes/version.php, on
PHP 8.5.11 built by Homebrew. Rails 8.1.4 on Ruby 4.0.5 (2026-05-20 revision 64336ffd0e), Puma 8.0.2,
mysql2 0.5.7. One MySQL 26.7.0 server on loopback serving both, shop_development for Rails and
wp_vs_rails for WordPress. 5000 products on each side, each with a title, a body, an integer price
and an integer stock level.
The Rails side is a products table and a two line model:
$ mysql -u root -h 127.0.0.1 -N -e "select group_concat(column_name order by ordinal_position) from information_schema.columns where table_schema='shop_development' and table_name='products';"
id,title,body,price,stock,created_at,updated_at
The WordPress side is a custom post type, registered in an mu-plugin, with price and stock as
custom fields. Everything that follows comes out of that one decision, which WordPress made for you
in 2003 and which you cannot revisit.
WordPress is not a slower Rails, it is a different bet
WordPress 7.1.2 is 3783 files, 1513 of them PHP, 635,414 lines of PHP and 114 MB on disk before you
add a theme. Counted from a fresh extraction of wordpress.tar.gz:
$ find . -type f | wc -l
3783
$ find . -name '*.php' -exec cat {} + | wc -l
635414
$ ls *.php | wc -l
15
Those 635,414 lines are not overhead, they are the product. They include a user system with roles, a
media library, revisions, a comment system, a taxonomy system, a REST API, an RSS feed, a cron
runner, and an admin interface that a person who does not write code can use to run a publication.
Rails ships none of that and does not intend to. The 15 top-level PHP files are the other side of the
same bet: every one of them is an entry point reachable over HTTP, xmlrpc.php included, and they are
yours to keep patched.
The honest framing is that WordPress is an application you extend and Rails is a library you build with. Everything below is a measurement of what extending costs when the thing you are building stops looking like a blog.
Where a custom field lives, and what that costs to query
Two custom fields on 5000 products is 10,000 extra rows in one key/value table. Not a metaphor, a count:
$ mysql -u root -h 127.0.0.1 -N -e "select count(*) from wp_vs_rails.wp_postmeta;"
10000
wp_posts has 23 columns and none of them is price. wp_postmeta has four, and the one holding
your value is longtext:
$ mysql -u root -h 127.0.0.1 wp_vs_rails -e "SHOW CREATE TABLE wp_postmeta"
CREATE TABLE `wp_postmeta` (
`meta_id` bigint unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint unsigned NOT NULL DEFAULT '0',
`meta_key` varchar(255) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
`meta_value` longtext COLLATE utf8mb4_unicode_520_ci,
PRIMARY KEY (`meta_id`),
KEY `post_id` (`post_id`),
KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB AUTO_INCREMENT=30007 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci
There is no index on meta_value and there is no useful one to add, because the column holds every
custom field of every post type in the installation and MySQL will not index a longtext without a
prefix length. Ask for products at 50 or under that are still in stock, and this is the SQL
WP_Query writes, printed from $q->request:
$ php wp-cli.phar eval-file ../scripts/query.php
--- SQL WP_Query generated ---
SELECT SQL_CALC_FOUND_ROWS 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 1=1 AND (
wp_postmeta.meta_key = 'price'
AND
(
( mt1.meta_key = 'price' AND CAST(mt1.meta_value AS SIGNED) <= '50' )
AND
( mt2.meta_key = 'stock' AND CAST(mt2.meta_value AS SIGNED) > '0' )
)
) AND ((wp_posts.post_type = 'product' AND (wp_posts.post_status = 'publish')))
GROUP BY wp_posts.ID
ORDER BY wp_postmeta.meta_value+0 ASC
LIMIT 0, 10
--- found 485 posts, page took 23.1 ms ---
Product 500 price=1 stock=20
Product 1500 price=1 stock=20
Product 2500 price=1 stock=20
Three self joins on wp_postmeta for two fields, a CAST on both of them, a GROUP BY to undo the
fan-out the joins created, and a sort on meta_value+0. The Rails version of the same question is
one statement and one index:
$ bin/rails runner /tmp/railsq2.rb
SELECT `products`.* FROM `products` WHERE `products`.`price` <= 50 AND (stock > 0) ORDER BY `products`.`price` ASC, `products`.`id` ASC LIMIT 10
found 485 products, 10 loaded, 3.7 ms
found 485 products, 10 loaded, 1.4 ms
found 485 products, 10 loaded, 1.0 ms
found 485 products, 10 loaded, 0.9 ms
found 485 products, 10 loaded, 0.8 ms
Both find 485 products and both name the same three first, which is the point of running them
against one database. What differs is what MySQL has to do, and the optimiser will say so if you ask
it. Rails, cost=225:
$ mysql -u root -h 127.0.0.1 shop_development -e "EXPLAIN SELECT * FROM products WHERE price <= 50 AND stock > 0 ORDER BY price, id LIMIT 10"
-> Limit: 10 row(s) (cost=225 rows=10)
-> Sort: products.price, products.id, limit input to 10 row(s) per chunk (cost=225 rows=500)
-> Index range scan on products using index_products_on_price_and_stock over (NULL < price <= 50), with index condition: ((products.price <= 50) and (products.stock > 0)) (cost=225 rows=500)
WordPress, cost=6034..6068, on the same server with the same 5000 rows:
-> Limit: 10 row(s)
-> Sort: (wp_postmeta.meta_value + 0), limit input to 10 row(s) per chunk
-> Table scan on <temporary> (cost=6034..6068 rows=2504)
-> Temporary table with deduplication (cost=6034..6034 rows=2504)
-> Nested loop inner join (cost=5457 rows=2504)
-> Nested loop inner join (cost=3726 rows=2504)
-> Nested loop inner join (cost=1995 rows=2504)
-> Covering index lookup on wp_posts using type_status_author (post_type = 'product', post_status = 'publish') (cost=264 rows=2504)
-> Filter: (wp_postmeta.meta_key = 'price') (cost=0.494 rows=1)
-> Index lookup on wp_postmeta using post_id (post_id = wp_posts.ID) (cost=0.494 rows=1.98)
-> Filter: ((mt1.meta_key = 'price') and (cast(mt1.meta_value as signed) <= '50')) (cost=0.494 rows=1)
-> Index lookup on mt1 using post_id (post_id = wp_posts.ID) (cost=0.494 rows=1.98)
-> Filter: ((mt2.meta_key = 'stock') and (cast(mt2.meta_value as signed) > '0')) (cost=0.494 rows=1)
-> Index lookup on mt2 using post_id (post_id = wp_posts.ID) (cost=0.494 rows=1.98)
A 27x difference in estimated cost that no amount of tuning removes, because it is not a tuning
problem. The temporary table and the filesort exist because the values being filtered and sorted are
not in a column, and the plugin ecosystem's answer to this is to add your own tables and query them
with $wpdb->get_results, at which point you are writing SQL by hand inside a CMS instead of using
an ORM.
One detail from the generated SQL that is worth its own line: SQL_CALC_FOUND_ROWS, which WordPress
puts on every archive query so it can print a pagination count, is deprecated. This MySQL says so
every time:
$ mysql -u root -h 127.0.0.1 wp_vs_rails -e "SELECT SQL_CALC_FOUND_ROWS ID FROM wp_posts WHERE post_type='product' LIMIT 3; SHOW WARNINGS;"
Level Code Message
Warning 1287 SQL_CALC_FOUND_ROWS is deprecated and will be removed in a future release. Consider using two separate queries instead.
The meta_query that answers 4460 instead of 500
meta_value is longtext, so a comparison against it is a string comparison unless you say
otherwise, and '9' <= '50' is false in string collation while 9 <= 50 is true in arithmetic. The
type key is what switches it, it is optional, and omitting it is silent:
$ php wp-cli.phar eval-file ../scripts/trap.php
type=(omitted) -> found_posts=4460
type=NUMERIC -> found_posts=500
Both numbers are wrong-looking until you see why: without the cast, MySQL keeps every price whose
decimal string sorts at or before "50", which is most of them. There is no error, no deprecation
notice and no log line. The page renders, the count is confident, and the only way to catch it is to
already know the trap is there.
The Rails equivalent of that mistake does not have a shape. price is an integer column, so
Product.where(price: ..50) compares integers because there is nothing else it could compare, and a
string in that column would have been rejected at write time.
wpdb has no transaction, and core never opens one
Writing to two tables and needing both writes or neither is the ordinary case in application code,
and WordPress has no API for it. wpdb exposes 59 public methods, and the list contains insert,
update, delete, replace and query, with nothing that begins, commits or rolls back:
$ grep -c 'public function' wp-includes/class-wpdb.php
59
$ grep -inE "function (begin|start_transaction|transaction|commit|rollback)" wp-includes/class-wpdb.php
$ echo $?
1
$ grep -rl "START TRANSACTION" wp-admin wp-includes | wc -l
0
Nothing in wp-admin or wp-includes issues the statement at all. In practice a plugin that needs
atomicity calls $wpdb->query('START TRANSACTION') by hand and hopes that no other code on the
request, in any of the other plugins installed, does the same thing or calls something that commits.
The schema does not help either, because there is nothing to enforce:
$ mysql -u root -h 127.0.0.1 -N -e "SELECT count(*) FROM information_schema.table_constraints WHERE table_schema = 'wp_vs_rails' AND constraint_type = 'FOREIGN KEY';"
0
Zero foreign keys across all 12 wp_ tables. wp_postmeta.post_id is a bigint unsigned with an
index on it and no reference to wp_posts.ID, which is why deleting a post in WordPress means
calling wp_delete_post() and letting PHP clean up the meta, the term relationships and the
comments, rather than letting the database do it.
The MySQL session WordPress gets is not the MySQL session Rails gets
wpdb removes the strict modes from its own connection at startup. Same server, same user, two
different sessions:
$ php wp-cli.phar eval-file ../scripts/sqlmode.php
wpdb session sql_mode: NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
$ bin/rails runner 'puts ActiveRecord::Base.lease_connection.select_value("SELECT @@SESSION.sql_mode")'
ONLY_FULL_GROUP_BY,NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
The server's own default has STRICT_TRANS_TABLES and ONLY_FULL_GROUP_BY in it. Active Record adds
STRICT_ALL_TABLES on top; wpdb takes both away. What that buys and costs is visible on one table
built for the purpose, strict_demo, with code varchar(5) NOT NULL and qty int NOT NULL, and one
INSERT that is wrong in two ways. Under wpdb:
wpdb insert returned: 1
row id=1 code=ABCDE qty=0
An INSERT that reports success, a code silently cut to five characters and the string 'twelve'
stored as 0. The same statement on the same table through Active Record:
ActiveRecord::ValueTooLong: Mysql2::Error: Data too long for column 'code' at row 1
Both behaviours are defensible and only one of them is a choice you can make. WordPress removes the
modes because 22 years of plugins were written against a MySQL that did not have them, and a strict
session would break installations on upgrade. The cost lands on your data, and there is no
define() that turns it back on for core's own writes. The wider story of the adapter and its
session setup is in the MySQL on Rails article.
There is a second consequence that is easy to miss. The archive SQL from the section above does not merely run slowly under the server's own settings, it does not run at all:
$ mysql -u root -h 127.0.0.1 wp_vs_rails -e "EXPLAIN SELECT wp_posts.ID FROM wp_posts INNER JOIN wp_postmeta ... GROUP BY wp_posts.ID ORDER BY wp_postmeta.meta_value+0 ASC LIMIT 0, 10"
ERROR 1055 (42000) at line 1: Expression #1 of ORDER BY clause is not in GROUP BY clause and contains nonaggregated column 'wp_vs_rails.wp_postmeta.meta_value' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
WordPress generates SQL that MySQL's default configuration rejects, and reaches it by turning the
check off. Producing that EXPLAIN at all required setting the session mode by hand to the three
modes wpdb leaves in place.
What WordPress ships that the Rails app answers 404 to
The back office is the feature with no Rails equivalent, and the gap is larger than "Rails has no admin". Standing the whole thing up from the tarball took 1.33 seconds on this laptop:
$ /usr/bin/time -p tar xzf ../wordpress.tar.gz
real 0.69
$ /usr/bin/time -p php ../../wp-cli.phar core install --url=http://127.0.0.1:8099 --title="Timing" --admin_user=admin --admin_password=pw --admin_email=a@b.test
Success: WordPress installed successfully.
real 0.64
Every one of these is a working screen on that install, fetched over HTTP with an admin cookie:
302 0b /wp-admin/ (no cookie)
200 133198b /wp-admin/ <title>Dashboard ‹ Bench — WordPress</title>
200 148372b /wp-admin/edit.php?post_type=product <title>Product ‹ Bench — WordPress</title>
200 186073b /wp-admin/post-new.php?post_type=product <title>Add Post ‹ Bench — WordPress</title>
200 158620b /wp-admin/upload.php <title>Media Library ‹ Bench — WordPress</title>
200 98038b /wp-admin/users.php <title>Users ‹ Bench — WordPress</title>
200 138343b /wp-admin/plugin-install.php <title>Add Plugins ‹ Bench — WordPress</title>
200 5703b /wp-login.php <title>Log In ‹ Bench — WordPress</title>
The product list screen paginates and searches 5000 rows with no work at all: edit.php?post_type=product&s=Product+4998
comes back with <span class="displaying-num">1 item</span>. The Rails application, serving the same
5000 products from the same MySQL:
rails /wp-admin/ -> 404
rails /admin -> 404
rails /wp-json/wp/v2/posts -> 404
rails /feed/ -> 404
What a Rails generator gives you against that is worth measuring rather than asserting.
bin/rails g scaffold Widget title:string body:text price:integer wrote 10 files and 171 lines:
11 db/migrate/20260927150540_create_widgets.rb
2 app/models/widget.rb
58 app/controllers/widgets_controller.rb
32 app/views/widgets/_form.html.erb
17 app/views/widgets/_widget.html.erb
12 app/views/widgets/edit.html.erb
16 app/views/widgets/index.html.erb
11 app/views/widgets/new.html.erb
10 app/views/widgets/show.html.erb
2 app/helpers/widgets_helper.rb
171 total
Working CRUD, and grep -rn "authenticate\|current_user" across all ten files exits 1. No
authentication, no roles, no file uploads, no revisions, no rich text, no search, no pagination, and
one model. WordPress had all of it before you opened an editor, plus 69,547 plugins and 8,726 themes
to bolt on, both counts read today from the results field of api.wordpress.org/plugins/info/1.2
and its themes equivalent. There is no comparable Rails number, because a gem is a library a
developer installs and a WordPress plugin is a feature a site owner installs, and pretending those
are the same unit would be the dishonest move on this page.
The price of that back office shows up in odd places. A GET on the "add new" screen writes to the
database:
$ mysql -N -e "select count(*) from wp_vs_rails.wp_posts"
5008
$ curl -s -o /dev/null -b /tmp/wpjar.txt "http://127.0.0.1:8080/wp-admin/post-new.php?post_type=product"
$ mysql -N -e "select count(*) from wp_vs_rails.wp_posts"
5009
$ mysql -N -e "select id, post_status, post_title from wp_vs_rails.wp_posts order by id desc limit 1"
15009 auto-draft Auto Draft
One idempotent-looking request, one new row, post_status auto-draft. It is how autosave and
revisions are built, it is documented behaviour, and it is also why a crawler with an admin session
grows your content table.
Twelve queries for a ten-row list
Both stacks were given an endpoint that renders the same thing: the ten most recent products as a
<ul>, no theme, no layout, 436 bytes on the WordPress side and 456 on the Rails side. WordPress
runs 12 SQL statements to produce it, captured with SAVEQUERIES and a shutdown hook:
1 [0.38ms] SELECT option_name, option_value FROM wp_options WHERE autoload IN ( 'yes', 'on', 'auto-on', 'auto' )
2 [0.24ms] SELECT option_value FROM wp_options WHERE option_name = 'WPLANG' LIMIT 1
3 [0.24ms] SELECT option_name, option_value FROM wp_options WHERE option_name IN ('_site_transient_wp_theme_files_patterns-c0ca1b1419e7fe87be287a50089a72cd','_site_transient_timeout_wp_theme_files_patterns-c0ca1b1419e7fe87be287a50089a72cd')
4 [0.12ms] SELECT option_value FROM wp_options WHERE option_name = 'theme_switched' LIMIT 1
5 [0.18ms] SELECT ID, post_name, post_parent, post_type FROM wp_posts WHERE post_name IN ('bench') AND post_type IN ('page','attachment')
6 [0.16ms] SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.post_name = 'bench' AND wp_posts.post_type = 'post' ORDER BY wp_posts.post_date DESC
7 [0.13ms] SELECT post_id FROM wp_postmeta, wp_posts WHERE ID = post_id AND post_type = 'post' AND meta_key = '_wp_old_slug' AND meta_value = 'bench'
8 [0.18ms] SELECT ID FROM wp_posts WHERE post_name LIKE 'bench%' AND post_type IN ('post', 'page', 'attachment', 'product') AND post_status IN ('publish')
9 [1.44ms] SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts WHERE 1=1 AND ((wp_posts.post_type = 'product' AND (wp_posts.post_status = 'publish'))) ORDER BY wp_posts.post_date DESC LIMIT 0, 10
10 [0.08ms] SELECT FOUND_ROWS()
11 [0.14ms] SELECT wp_posts.* FROM wp_posts WHERE ID IN (15003,15002,15001,15000,14999,14998,14997,14996,14995,14994)
12 [0.17ms] SELECT post_id, meta_key, meta_value FROM wp_postmeta WHERE post_id IN (15003,15002,15001,15000,14999,14998,14997,14996,14995,14994) ORDER BY meta_id ASC
Queries 5 through 8 are the interesting ones. My handler hangs off template_redirect and never
needs any of them: WordPress is working out whether /bench is a page, a post, an old slug that
should 301, or a near miss it should guess at, and it asks the database four times before the code
that answers the request gets to run. Query 1 loads every autoloaded row in wp_options in one go,
which is the thing that quietly becomes a problem on a site with 40 plugins. Queries 11 and 12 are
the two-step WordPress uses instead of a join, and 12 is the part that makes custom fields cheap to
read once you already have the ids.
Rails runs one:
1 [2.53ms] SELECT `products`.* FROM `products` ORDER BY `products`.`created_at` DESC LIMIT 10
Twelve against one is not twelve times slower, and the timings above show why: the whole WordPress set is about 3.0 ms of SQL. What it is, is twelve round trips of latency you did not ask for, over a connection that in production is not on loopback. It is also the reason N+1 hunting has no real WordPress equivalent: you cannot preload what you did not write.
The throughput number, and the benchmark I got wrong twice
Same 10-product list, same MySQL, four worker processes on each side, each handling one request at a
time. WordPress on PHP's built-in server with PHP_CLI_SERVER_WORKERS=4, Rails on Puma 8.0.2 with
WEB_CONCURRENCY=4 RAILS_MAX_THREADS=1 in RAILS_ENV=production:
$ /usr/sbin/ab -n 3000 -c 4 http://127.0.0.1:8080/bench
Document Length: 436 bytes
Failed requests: 0
Requests per second: 402.11 [#/sec] (mean)
50% 10
95% 12
99% 18
$ /usr/sbin/ab -n 3000 -c 4 http://127.0.0.1:3947/bench
Document Length: 456 bytes
Failed requests: 0
Requests per second: 1065.55 [#/sec] (mean)
50% 3
95% 6
99% 7
2.65x, with a median of 3 ms against 10 ms. State the limit plainly: php -S is not how anybody runs
WordPress, php-fpm behind nginx is, and neither was configured here, so treat the ratio as indicative
and the 12-against-1 query count as the server-independent part.
The first run of that benchmark was wrong, and the way it was wrong is worth the paragraph. PHP's
opcode cache is the single largest factor in WordPress performance, so the fair thing was to make
sure it was on, and php --ini reported opcache.enable_cli=0. Starting the server with
php -d opcache.enable_cli=1 changed the result by about 1%, which made no sense. It made no sense
because opcache.enable_cli was never being consulted:
$ curl -s http://127.0.0.1:8080/sapi.php
php_sapi_name=cli-server
opcache.enable=0 opcache.enable_cli=0
opcache_enabled=false
The built-in server's SAPI name is cli-server, not cli, so the enable_cli guard does not apply
to it and opcache.enable=1 from php.ini had opcache running all along. The flag was a no-op in
both directions. Turning it off needs opcache.enable=0, and that is the number worth keeping:
$ /usr/sbin/ab -n 1000 -c 4 http://127.0.0.1:8080/bench
Requests per second: 34.87 [#/sec] (mean)
50% 110
34.87 requests per second and a 110 ms median, against 402.11 with the cache on. Recompiling on every
request, and opcache_get_status() reported 489 cached scripts for this one, costs WordPress 11x its
throughput, which is the first thing to check on an install that is inexplicably slow. Ruby has no
equivalent knob, because the VM keeps compiled code for the life of the process.
The second wrong run was shorter and more WordPress-specific. Moving the server to port 8081 to run
the two configurations side by side produced a suspiciously fast 517 requests per second, because
every one of them was a 301: wp_options holds siteurl and home as
http://127.0.0.1:8080, and WordPress redirects anything that does not match. A WordPress site's own
origin is a row in a table, not configuration, which is a thing to know before a domain change.
A full page cache makes most of the paragraph above irrelevant
The strongest argument against the throughput section is the way WordPress is actually deployed. Its
themed product archive, the real 78,716 byte page rather than my bare <ul>, serves 112.73 requests
per second. Saved to disk and served as a static file by the same process:
$ /usr/sbin/ab -n 3000 -c 4 http://127.0.0.1:8080/cached-archive.html
Document Length: 78716 bytes
Requests per second: 6445.67 [#/sec] (mean)
50% 1
6445.67 against 112.73, a 57x, for the identical bytes. That is what a full page cache plugin does, and on a site whose pages are the same for everyone it is the correct answer. Anyone quoting a framework benchmark at a brochure site is measuring the wrong thing, and the same logic applies to the Rails side, where the caching strategies that matter are the ones that stop the request before it reaches a controller.
Two things a cache does not fix, and they are the two that decided this page. A logged-in request
cannot be served from a shared cache, so anything with accounts in it runs the dynamic path. And a
cache does nothing about the cost=6034 query plan or the missing transaction, because those are
about writes and about queries nobody can cache.
The call, and what would change it
If what you are building is content, edited by people who do not write code, with a shape that is articles and pages and a handful of extra fields, use WordPress. Rails will not catch up, and the reason is not the framework: it is that the 635,414 lines above contain a media library, a revision system, a role system and 69,547 plugins, and choosing Rails means agreeing to build the parts of that you need. The 171-line scaffold with no authentication in it is an honest picture of the starting line.
If what you are building has data whose shape is not posts, use Rails. Specifically: if any answer
you need requires an index on two attributes at once, or a foreign key you want the database to
enforce, or two writes that must both happen or neither, then WordPress is asking you to solve those
problems in PHP on top of a schema that was designed to refuse them. The three measurements that make
that concrete are cost=6034..6068 against cost=225, 0 foreign keys, and 0 occurrences of
START TRANSACTION in 1513 files of core. A Rails transaction
is four characters of API surface and WordPress has no equivalent at any length.
What would change the first half: nothing on the horizon. Rails is not going to grow an admin, and that is a deliberate position rather than an oversight.
What would change the second half: a wpdb with a real transaction API, and custom post types that
can own typed columns in their own table rather than renting wp_postmeta. Both have been argued
about in WordPress for years and neither is close, and if either shipped most of this page would
need rewriting.
What should not change either half is the 402 against 1065. Requests per second is the most quotable
number here and the least useful, because 6445.67 sits three paragraphs up and was produced by
cp.
What this post does not cover
No WooCommerce, which is where most non-trivial WordPress money lives, and which adds its own tables
and would change the schema argument in ways worth measuring separately. No Gutenberg block
development, no ACF, no custom-table plugins such as the ones used to escape wp_postmeta. No
headless WordPress, so no WPGraphQL and no comparison of the REST API to a Rails JSON endpoint. No
multisite.
Nothing about hosting, cost or migration tooling, because none of those can be measured from a
laptop, and nothing about the two hiring markets. No nginx and no php-fpm, as stated above, so every
throughput number here came from PHP's built-in server and Puma on loopback. No security comparison
beyond counting entry points: the sentence about 15 top-level PHP files and xmlrpc.php is a count,
not a vulnerability claim, and no CVE is cited on this page.
Two things I could not verify and therefore did not claim. No market share figure appears here, the
"WordPress runs a third of the web" number included, because the sources for it are third-party
crawls that I did not run. And nothing about how WordPress behaves on a real production stack under
load, since the closest thing to one on this machine was a php -S process with four workers.
Comments
No comments yet. Be the first.