LaunchKit
← All posts
· 21 min read · by The LaunchKit team · 0 views

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 &lsaquo; Bench &#8212; WordPress</title>
200 148372b /wp-admin/edit.php?post_type=product <title>Product &lsaquo; Bench &#8212; WordPress</title>
200 186073b /wp-admin/post-new.php?post_type=product <title>Add Post &lsaquo; Bench &#8212; WordPress</title>
200 158620b /wp-admin/upload.php <title>Media Library &lsaquo; Bench &#8212; WordPress</title>
200 98038b /wp-admin/users.php <title>Users &lsaquo; Bench &#8212; WordPress</title>
200 138343b /wp-admin/plugin-install.php <title>Add Plugins &lsaquo; Bench &#8212; WordPress</title>
200 5703b /wp-login.php <title>Log In &lsaquo; Bench &#8212; 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.

#rails #wordpress #comparison

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.