Database Schema

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

WordPress keeps content, users, taxonomies, comments and settings in a small, fixed set of MySQL or MariaDB tables. Most plugins add no tables: custom content becomes posts of a custom post type, extra fields become metadata rows and settings become options. The schema explains why some queries are slow, what a backup and a migration must handle, and why a Multisite network has so many tables.

This page was written against WordPress 7.1.3 (released 2026-10-06; database version 61833; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, MariaDB 11.8 and WP-CLI 2.12.0, and checked on 2026-10-28. Every CREATE TABLE statement and every query result below comes from a fresh 7.1.3 installation, later converted to Multisite. WordPress 7.2 (in beta, planned for 2026-12-08) adds options but no table changes so far. The requirements page recommends MySQL 8.0+ or MariaDB 10.11+; the schema is the same on all supported servers.

Where the schema is defined

There is no migration framework. The canonical definition is the function wp_get_db_schema() in wp-admin/includes/schema.php, which returns the CREATE TABLE statements for a scope (all, blog, global or ms_global). The installer and every upgrade pass that SQL to dbDelta(), which compares it with the live tables and adds missing tables, columns and indexes. Targeted changes dbDelta() cannot do (dropping an index, changing a column type on Multisite tables) live in pre_schema_upgrade() and the upgrade_*() functions in wp-admin/includes/upgrade.php, gated by the stored db_version option against $wp_db_version in wp-includes/version.php; when the code is newer, WordPress shows "Database Update Required" (or run wp core update-db). The old Codex "Database Description" page predates several changes below; read schema.php for the version you run, or ask the database.

A site to inspect

The Compose file below starts MariaDB and a WP-CLI container, which downloads and installs core itself. It is the environment used for every result on this page; the passwords are placeholders for a local machine only.

# compose.yaml
name: lumen-schema
services:
  db:
    image: mariadb:11.8
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: ${WP_DB_PASSWORD:?set WP_DB_PASSWORD}
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
  cli:
    image: wordpress:cli-2.12.0-php8.4
    depends_on: [db]
    user: "33:33"
    environment:
      WP_DB_PASSWORD: ${WP_DB_PASSWORD}
      WP_ADMIN_PASSWORD: ${WP_ADMIN_PASSWORD:?set WP_ADMIN_PASSWORD}
    volumes: [wp:/var/www/html]
    entrypoint: ["sleep", "infinity"]
volumes:
  wp: {}
export WP_DB_PASSWORD='<a-local-db-password>' WP_ADMIN_PASSWORD='<a-local-admin-password>'
docker compose up -d
docker compose exec cli sh -c 'php -d memory_limit=512M /usr/local/bin/wp core download --version=7.1.3 &&
  wp config create --dbname=wordpress --dbuser=wordpress --dbpass="$WP_DB_PASSWORD" --dbhost=db &&
  wp core install --url=http://lumenpress.test --title="Lumen Press" --admin_user=admin \
    --admin_email=admin@lumenpress.test --admin_password="$WP_ADMIN_PASSWORD" --skip-email'
docker compose exec cli wp db tables
docker compose exec cli wp db query 'SHOW CREATE TABLE wp_posts\G'

wp db tables lists only the tables registered on $wpdb; --all-tables-with-prefix adds plugin tables that share the prefix and --all-tables lists everything in the database. On a single site the first command prints exactly the 12 tables of the next section.

The 12 single-site tables

erDiagram wp_users ||--o{ wp_usermeta : "user_id" wp_users ||--o{ wp_posts : "post_author" wp_users ||--o{ wp_comments : "user_id" wp_users ||--o{ wp_links : "link_owner" wp_posts ||--o{ wp_postmeta : "post_id" wp_posts ||--o{ wp_posts : "post_parent" wp_posts ||--o{ wp_comments : "comment_post_ID" wp_comments ||--o{ wp_commentmeta : "comment_id" wp_comments ||--o{ wp_comments : "comment_parent" wp_terms ||--o{ wp_term_taxonomy : "term_id" wp_terms ||--o{ wp_termmeta : "term_id" wp_term_taxonomy ||--o{ wp_term_relationships : "term_taxonomy_id" wp_posts ||--o{ wp_term_relationships : "object_id" wp_options { bigint option_id PK varchar option_name UK longtext option_value varchar autoload }

The relationships in the diagram are logical. The tables use InnoDB but declare no foreign keys: integrity is kept by the PHP API (wp_delete_post() deletes the post’s meta, comments and term relationships), so rows written with raw SQL can leave orphans behind. wp_options has no relationship to the other tables.

Table Holds, and what to know about it

wp_posts

Every piece of content: posts, pages, attachments, revisions, menus, patterns (wp_block), templates (wp_template, wp_template_part), global styles, fonts and custom post types such as lp_book. post_type is varchar(20), hence the 20-character limit of register_post_type(). Blocks are stored in post_content as HTML with <!-- wp:... --> delimiters.

wp_postmeta

Key/value pairs per post: custom fields, _thumbnail_id, _wp_attached_file, _edit_lock. Keys starting with _ are hidden from the Custom Fields panel; arrays are PHP-serialized.

wp_users

Accounts. user_pass is varchar(255), wide enough for the bcrypt hashes used since 6.8.

wp_usermeta

Per-user data: roles (wp_capabilities), wp_user_level, preferences, sessions. The role keys carry the table prefix.

wp_terms

Term names and slugs. Since 4.2 a new term belongs to exactly one taxonomy; older shared terms were split.

wp_term_taxonomy

The term in a taxonomy (category, post_tag, lp_genre) with parent, description and a cached count. taxonomy is varchar(32), the taxonomy key length limit.

wp_term_relationships

Which object has which term (object_id, term_taxonomy_id); object_id is a post ID, or a link ID for link categories.

wp_termmeta

Term metadata, added in 4.4.

wp_comments

Comments, pingbacks, trackbacks and, since 6.9, editorial notes on blocks (comment_type note). comment_approved holds 0, 1, spam or trash.

wp_commentmeta

Comment metadata, such as spam-check results or the status before trashing.

wp_links

The Links (blogroll) manager, hidden since 3.5 on sites without links; the table is still created on every install.

wp_options

Site settings and plugin data: siteurl, home, active_plugins, cron, transients, db_version.

The actual definition of the content table, as MariaDB reports it after a 7.1.3 install:

CREATE TABLE `wp_posts` (
  `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `post_author` bigint(20) unsigned NOT NULL DEFAULT 0,
  `post_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_date_gmt` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_content` longtext NOT NULL,
  `post_title` text NOT NULL,
  `post_excerpt` text NOT NULL,
  `post_status` varchar(20) NOT NULL DEFAULT 'publish',
  `comment_status` varchar(20) NOT NULL DEFAULT 'open',
  `ping_status` varchar(20) NOT NULL DEFAULT 'open',
  `post_password` varchar(255) NOT NULL DEFAULT '',
  `post_name` varchar(200) NOT NULL DEFAULT '',
  `to_ping` text NOT NULL,
  `pinged` text NOT NULL,
  `post_modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_modified_gmt` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  `post_content_filtered` longtext NOT NULL,
  `post_parent` bigint(20) unsigned NOT NULL DEFAULT 0,
  `guid` varchar(255) NOT NULL DEFAULT '',
  `menu_order` int(11) NOT NULL DEFAULT 0,
  `post_type` varchar(20) NOT NULL DEFAULT 'post',
  `post_mime_type` varchar(100) NOT NULL DEFAULT '',
  `comment_count` bigint(20) NOT NULL DEFAULT 0,
  PRIMARY KEY (`ID`),
  KEY `post_name` (`post_name`(191)),
  KEY `type_status_date` (`post_type`,`post_status`,`post_date`,`ID`),
  KEY `post_parent` (`post_parent`),
  KEY `post_author` (`post_author`),
  KEY `type_status_author` (`post_type`,`post_status`,`post_author`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci

Some columns are worth knowing by name. guid is a permanent identifier for feeds and must not be rewritten during a domain migration, even though it looks like a URL. post_content_filtered, to_ping and pinged are legacy and almost always empty. Dates are stored twice, in site time and in UTC (_gmt), and the zero date 0000-00-00 00:00:00 is used for drafts that have no publish date yet, which is why a MySQL server in strict NO_ZERO_DATE mode can reject WordPress’s own writes; WordPress sets a compatible SQL mode per connection.

Indexes

Table Indexes besides the primary key

wp_posts

post_name(191), type_status_date, post_parent, post_author, type_status_author (see the definition above)

wp_postmeta, wp_commentmeta, wp_termmeta, wp_usermeta

the parent ID column and meta_key(191)

wp_users

user_login_key (user_login), user_nicename, user_email

wp_terms

slug(191), name(191)

wp_term_taxonomy

unique term_id_taxonomy (term_id, taxonomy), taxonomy

wp_term_relationships

term_taxonomy_id (the primary key is object_id, term_taxonomy_id)

wp_comments

comment_post_ID, comment_approved_date_gmt, comment_date_gmt, comment_parent, comment_author_email(10)

wp_options

unique option_name, autoload

wp_links

link_visible

The (191) prefix lengths date from the utf8mb4 conversion in 4.2: a utf8mb4 character can take 4 bytes, and the classic InnoDB index key limit of 767 bytes fits 191 of them. schema.php still sets $max_index_length = 191 even though modern InnoDB (DYNAMIC row format) allows 3072 bytes.

The newest index, type_status_author, was added in WordPress 6.9 to speed up queries that filter by post type, status and author on sites with many posts. In the same release the ID columns of the Multisite tables became unsigned to match the single-site tables.

An older handbook recommends adding an index on post_name to speed up permalink lookups. That index has existed for as long as the table has (as post_name(191) since 4.2), so the advice adds a duplicate index that costs write time and buys nothing; wp db query "SHOW INDEX FROM wp_posts" shows what exists before adding anything.

Adding your own index to a core table is possible, but it is not part of the schema WordPress maintains: dbDelta() does not drop unknown indexes, a reinstall or migration tool may not carry it, and a future core index can duplicate it. Document it and prefer the access pattern that uses the existing indexes.

The posts/postmeta model and its query cost

wp_posts has a fixed set of columns. Anything else a book needs (ISBN, page count, publication date) goes in wp_postmeta as one row per attribute. This is an entity-attribute-value (EAV) design: it lets any plugin add fields without altering the schema, and it makes filtering and sorting by those fields expensive. Lumen Press registers its book meta so that it is typed, sanitized and visible in the REST API (the full plugin is on Custom Post Types and Taxonomies and Metadata and Custom Fields):

<?php
/**
 * Plugin Name: Lumen Catalog (schema excerpt)
 */

add_action( 'init', function () {
	register_post_type( 'lp_book', array(
		'label'        => 'Books',
		'public'       => true,
		'show_in_rest' => true,
		'supports'     => array( 'title', 'editor', 'custom-fields' ),
	) );
	register_taxonomy( 'lp_genre', 'lp_book', array( 'label' => 'Genres', 'show_in_rest' => true ) );

	$fields = array( 'lp_isbn' => 'string', 'lp_pages' => 'integer', 'lp_published_on' => 'string' );
	foreach ( $fields as $key => $type ) {
		register_post_meta( 'lp_book', $key, array(
			'type'         => $type,
			'single'       => true,
			'show_in_rest' => true,
		) );
	}
} );

Registering meta does not change storage. Two books created with WP-CLI become two wp_posts rows and six wp_postmeta rows, all values stored as text:

$ wp db query "SELECT p.ID, p.post_title, m.meta_key, m.meta_value FROM wp_posts p
    JOIN wp_postmeta m ON m.post_id = p.ID WHERE p.post_type = 'lp_book' ORDER BY p.ID, m.meta_key"
ID  post_title          meta_key         meta_value
4   The Lantern Keeper  lp_isbn          978-0-00-000001-9
4   The Lantern Keeper  lp_pages         312
4   The Lantern Keeper  lp_published_on  2026-03-14
5   Night Ferry         lp_isbn          978-1-23456-793-4
5   Night Ferry         lp_pages         248
5   Night Ferry         lp_published_on  2025-11-02

A WP_Query for long fantasy books, sorted by page count, shows the price of the model. Save it as query-demo.php in the WordPress root and run it with wp eval-file query-demo.php:

<?php
$q = new WP_Query( array(
	'post_type'      => 'lp_book',
	'posts_per_page' => 10,
	'meta_key'       => 'lp_pages',
	'orderby'        => 'meta_value_num',
	'order'          => 'DESC',
	'meta_query'     => array(
		array(
			'key'     => 'lp_pages',
			'value'   => 300,
			'compare' => '>=',
			'type'    => 'NUMERIC',
		),
	),
	'tax_query'      => array(
		array(
			'taxonomy' => 'lp_genre',
			'field'    => 'slug',
			'terms'    => 'fantasy',
		),
	),
) );

echo wp_list_pluck( $q->posts, 'post_title' )[0] ?? 'none', "\n", $q->request, "\n";

The generated SQL (whitespace condensed) joins wp_postmeta twice, once for the ordering key and once for the condition, plus wp_term_relationships for the genre:

SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
LEFT JOIN wp_term_relationships ON (wp_posts.ID = wp_term_relationships.object_id)
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 )
WHERE 1=1
  AND ( wp_term_relationships.term_taxonomy_id IN (2) )
  AND ( wp_postmeta.meta_key = 'lp_pages'
        AND ( ( mt1.meta_key = 'lp_pages' AND CAST(mt1.meta_value AS SIGNED) >= '300' ) ) )
  AND ((wp_posts.post_type = 'lp_book' AND (wp_posts.post_status = 'publish')))
GROUP BY wp_posts.ID
ORDER BY wp_postmeta.meta_value+0 DESC
LIMIT 0, 10

What makes this costly:

  • every additional meta condition adds another self-join of wp_postmeta, the largest table on most sites;

  • meta_value is longtext and is not indexed, so the CAST(... AS SIGNED) comparison and the meta_value+0 ordering are evaluated row by row for every row that matches meta_key;

  • EXPLAIN on MariaDB 11.8 shows the meta join driven by the meta_key(191) index (ref, key length 767), which narrows rows by key but not by value;

  • SQL_CALC_FOUND_ROWS counts all matches for pagination; pass 'no_found_rows' => true when you do not paginate.

On a catalogue of a few thousand books this is fine. It degrades when wp_postmeta reaches millions of rows and the query combines several meta conditions with sorting. The usual remedies, in order: filter by taxonomy instead of meta whenever the value is a category (genre, series, format), because term queries use proper integer indexes; keep the number of meta conditions small; cache the result (see Options, Transients and Object Cache); and, for data that is queried relationally, use a custom table created with dbDelta() (see WP_Query and the Database API). The background on normal forms and joins is in Normalization and DML Queries.

The same EAV pattern applies to wp_usermeta, wp_termmeta and wp_commentmeta, and on Multisite to wp_blogmeta and wp_sitemeta. All share the get_metadata() family of functions.

Options and autoload

wp_options is a key/value store. On every request WordPress runs wp_load_alloptions(), which reads all autoloaded options in one query and caches them; any other option costs a separate query the first time it is read (unless a persistent object cache holds it). Large autoloaded options therefore slow every page, including pages that never use them.

The table has four columns: option_id, option_name (varchar(191), unique), option_value (longtext) and autoload (varchar(20), indexed).

Until 6.5 the autoload column held yes or no, and add_option() defaulted to yes. WordPress 6.6 changed the values and the default:

Value Meaning

on

Explicitly autoloaded (true passed to add_option(), update_option() or wp_set_option_autoload()).

off

Explicitly not autoloaded (false).

auto

No explicit choice; WordPress decides. Autoloaded in 6.6 and later, but core may change that.

auto-on

No explicit choice and WordPress decided to autoload.

auto-off

No explicit choice and WordPress decided not to, because the value is larger than 150,000 bytes (filter wp_max_autoloaded_option_size).

yes, no

Legacy values, still honoured. Passing 'yes' or 'no' to the functions is deprecated since 6.7.

The set of values that count as autoloaded is yes, on, auto-on and auto, filterable with wp_autoload_values_to_autoload (the filter can only remove values). Site Health reports a critical issue, "Autoloaded options could affect performance", when the autoloaded total passes 800 KB. The column default in the table is still 'yes', so a row inserted by raw SQL without the column becomes autoloaded.

Lumen Catalog stores its distributor settings, read only on its own admin screen and by the stock-sync cron job, with autoload explicitly off:

<?php
add_option(
	'lp_catalog_settings',
	array( 'distributor_url' => 'https://distributor.example' ),
	'',
	false // Not autoloaded: only the settings page and the cron job read it.
);

// Switch existing options without rewriting their values (since 6.4).
wp_set_option_autoload_values(
	array(
		'lp_catalog_settings' => false,
		'lumen_catalog_db_version'  => true,
	)
);

To see the distribution on a site, and the real autoloaded size (which wp option list --autoload=on understates, because it only counts the on rows):

wp db query "SELECT autoload, COUNT(*) AS options, SUM(LENGTH(option_value)) AS bytes
  FROM wp_options GROUP BY autoload"
wp db query "SELECT SUM(LENGTH(option_value)) AS autoloaded_bytes FROM wp_options
  WHERE autoload IN ('yes','on','auto-on','auto')"
wp option list --autoload=on --format=total_bytes

On the fresh 7.1.3 install the first query returned 100 on rows (37,166 bytes), 16 auto rows (1,508 bytes) and 10 off rows; the second returned 38,675 bytes and wp option list returned 37,166. An option of 200,000 bytes added without an autoload argument was stored as auto-off. Note that wp option add without --autoload stores on, unlike add_option() in PHP.

Table prefix

$table_prefix in wp-config.php (default wp_) is prepended to every table name and is exposed as $wpdb->prefix. Code must never hard-code wp_; use the table properties ($wpdb->posts, $wpdb->options) or {$wpdb->prefix} for custom tables:

<?php
global $wpdb;

$long_books = $wpdb->get_col(
	$wpdb->prepare(
		"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND CAST(meta_value AS UNSIGNED) >= %d",
		'lp_pages',
		300
	)
);

The prefix may contain only letters, digits and underscores. It lets several installations share one database, and it appears inside data as well as in table names: the wp_user_roles option and the wp_capabilities and wp_user_level user meta keys carry it. Renaming the tables alone therefore locks every user out of the admin; changing the prefix of an existing site means renaming the tables and updating those option and meta keys (see Importing, Exporting and Migrating). A non-default prefix is sometimes sold as a security measure. It only defeats the crudest automated SQL injection payloads that assume wp_; it does nothing against an injection that can query information_schema. Fix the injection instead.

Character set and collation

Since 4.2, WordPress creates and converts tables to utf8mb4, the MySQL character set that stores all of Unicode, including emoji (the older utf8, an alias of utf8mb3, stops at 3 bytes per character). wpdb::determine_charset() upgrades a configured utf8 to utf8mb4 when the server supports it, and picks utf8mb4_unicode_520_ci as the collation when available. That is why the test install above, whose wp-config.php was generated by wp config create with DB_CHARSET 'utf8', still produced utf8mb4 tables; the wp-config-sample.php shipped with core already says utf8mb4.

In wp-config.php, set DB_CHARSET to utf8mb4 and leave DB_COLLATE empty unless the database already uses a specific collation. Mismatched collations between a custom table and a core table produce "Illegal mix of collations" errors in joins; create custom tables with $wpdb->get_charset_collate(). When moving a database between servers, make sure the dump and the import both use utf8mb4, otherwise accented characters and emoji turn into mojibake.

Multisite tables

Converting the site to a network (subdirectories, wp core multisite-convert) and creating the two imprint sites Lumen Kids and Lumen Noir adds six network tables and one set of ten tables per new site:

wp core multisite-convert --title="Lumen Press Network"
wp site create --slug=kids --title="Lumen Kids"
wp site create --slug=noir --title="Lumen Noir"
wp db tables --network
wp db tables --scope=blog --url=lumenpress.test/kids/
erDiagram wp_site ||--o{ wp_sitemeta : "site_id" wp_site ||--o{ wp_blogs : "site_id" wp_blogs ||--o{ wp_blogmeta : "blog_id" wp_blogs ||--o{ wp_registration_log : "blog_id" wp_blogs ||--|| wp_N_tables : "prefix wp_N_" wp_users ||--o{ wp_usermeta : "user_id" wp_signups { bigint signup_id PK }
Table Holds

wp_site

Networks: one row per network (domain, path). Most installations have one.

wp_sitemeta

Network options: site_admins, main_site, siteurl, upload limits, active_sitewide_plugins.

wp_blogs

Sites: domain and path, flags public, archived, mature, spam, deleted, timestamps.

wp_blogmeta

Site metadata (since 5.1), read and written with get_site_meta() and update_site_meta(); empty on a new network.

wp_signups

Pending user and site registrations with their activation keys.

wp_registration_log

Who registered which site, from which IP address.

The naming is historical: in the database a "site" (wp_site) is a network and a "blog" (wp_blogs) is a site, the opposite of the admin screens. After the commands above, wp_blogs holds blog IDs 1 (/), 2 (/kids/) and 3 (/noir/), all with site_id 1.

The table layout follows these rules:

  • wp_users and wp_usermeta are global: one account works on every site, and roles are stored per site as wp_2_capabilities, wp_3_capabilities in wp_usermeta. On Multisite wp_users gains spam and deleted.

  • The main site keeps the base prefix (wp_posts, wp_options), and $wpdb->get_blog_prefix(1) returns wp_.

  • Every other site gets ten tables prefixed wp_N_ (wp_2_posts, wp_2_options, …​, wp_2_links): the single-site list without wp_users and wp_usermeta. The count has been ten since termmeta arrived in 4.4.

  • $wpdb->prefix changes with switch_to_blog(); $wpdb->base_prefix does not. Network-level code that builds table names must use base_prefix for global tables.

So a network with 100 sites has 6 network tables, the 12 tables of the main site and 99 × 10 = 990 more: 1,008 in all. Backups, information_schema queries and some managed database plans with table limits feel that number.

Multisite table counts in older administration books predate wp_blogmeta (5.1) or termmeta (4.4); current versions create six network tables, share the users tables, and add ten tables per additional site. The domain-mapping plugin some of them describe is not needed either: since 4.5, a site’s domain is the domain column of wp_blogs, edited from Network Admin. Multisite setup and operation are on Multisite Networks, and code that must work across sites on Multisite Development.

The conversion also writes MULTISITE, SUBDOMAIN_INSTALL, DOMAIN_CURRENT_SITE, PATH_CURRENT_SITE, SITE_ID_CURRENT_SITE (a wp_site.id) and BLOG_ID_CURRENT_SITE (the main site’s wp_blogs.blog_id) to wp-config.php, as described in Create A Network.

Common errors and pitfalls

Symptom Cause and fix

The installer appears, or administrators get "Sorry, you are not allowed to access this page", after changing $table_prefix

WordPress finds no options table with the new prefix, or user roles are lost because the wp_capabilities and wp_user_roles keys still carry the old prefix. Rename tables and keys together.

"Database Update Required" after a core update

db_version is older than $wp_db_version. Run it from the screen or with wp core update-db (--network on Multisite). Take a backup first.

Slow pages and Site Health "Autoloaded options could affect performance"

Large autoloaded options, often left by deactivated plugins. Find them by size and set autoload to off with wp_set_option_autoload_values() or wp option set-autoload.

Slow admin lists or archives filtered by custom fields

Multiple meta_query clauses on a large wp_postmeta. Move categorical values to a taxonomy, cache, or use a custom table.

Orphaned wp_postmeta or wp_term_relationships rows

Rows deleted with raw SQL instead of the API; there are no foreign keys. Delete through wp_delete_post() or wp post delete.

Broken serialized data after a search-and-replace migration

A plain SQL REPLACE() changed string lengths inside PHP-serialized values. Use wp search-replace, which unserializes first, and do not touch the guid column.

"Illegal mix of collations" in a plugin query

A custom table created without $wpdb->get_charset_collate(). Convert it to the site’s collation.

wp-config.php Reference, WP_Query and the Database API, Options, Transients and Object Cache, Performance and Caching, Backups and Disaster Recovery, WP-CLI, DDL and Redis (the persistent object cache that relieves option and meta lookups). WooCommerce adds its own order and lookup tables; they are covered in Architecture and HPOS: High-Performance Order Storage.