Atualizado em 19/06/2026
O WordPress guarda quase tudo em cinco tabelas centrais — wp_posts, wp_postmeta, wp_options e companhia — e, na imensa maioria dos casos, é onde os seus dados devem ficar. Mas há cenários em que forçar dados de aplicação dentro da estrutura chave-valor da wp_postmeta vira um gargalo de performance e um pesadelo de consulta. Para esses casos existe a possibilidade de criar tabelas próprias, e o WordPress oferece uma função específica para isso: a dbDelta(). Ela é poderosa — cria e migra tabelas comparando estruturas — mas tem regras de formatação de SQL surpreendentemente rígidas, e ignorá-las gera bugs sutis. Vamos cobrir a decisão (quando usar), a criação correta, a migração entre versões e o CRUD seguro.
Antes de tudo: você realmente precisa de uma tabela própria?
Esta é a decisão mais importante do artigo, e a resposta certa na maioria das vezes é "não". Criar uma tabela própria significa abrir mão de tudo que o WordPress dá de graça: integração com o admin, REST API, permissões, cache de objeto, busca, exportação. Avalie as alternativas primeiro:
| Abordagem | Use quando | Evite quando |
|---|---|---|
Options API (wp_options) | Poucas configurações globais do site/plugin | Dados por usuário/registro, ou que crescem sem limite (autoload pesa) |
Post meta (wp_postmeta) | Atributos extras de um post existente, poucos por post | Filtrar/ordenar por esses valores em grande volume (JOINs caros) |
| Custom Post Type | Os dados são "conteúdo": têm autor, status, listagem, aparecem no admin | São dados de aplicação puros, sem cara de conteúdo editorial |
| Tabela própria | Dados relacionais, alto volume, consultados por colunas específicas (logs, transações, relações N:N) | Cabem confortavelmente em uma das opções acima |
O teste mental que uso: "isto é conteúdo editorial ou são dados de aplicação?" Se um editor fosse listar, buscar ou revisar esses registros no painel, provavelmente é um Custom Post Type. Se são linhas de log, itens de uma relação muitos-para-muitos, ou uma série temporal consultada por data e ID — aí sim a tabela própria se justifica, porque a wp_postmeta não foi feita para WHERE coluna = ? ORDER BY outra_coluna em milhões de linhas.
Nome da tabela e charset
Decidido que a tabela é necessária, duas regras antes do SQL. Primeiro, o nome sempre usa $wpdb->prefix — nunca escreva wp_ à mão, porque o prefixo é configurável e, em Multisite, varia por subsite. Segundo, use $wpdb->get_charset_collate() para herdar o charset e o collation da instalação (tipicamente utf8mb4), garantindo suporte completo a Unicode e consistência com o resto do banco.
Criando a tabela na ativação
A criação roda no hook de ativação do plugin. A função dbDelta() não vem carregada por padrão — você precisa incluir manualmente o arquivo wp-admin/includes/upgrade.php. Veja o esqueleto completo:
<?php
register_activation_hook( __FILE__, 'minha_loja_criar_tabela' );
function minha_loja_criar_tabela() {
global $wpdb;
$tabela = $wpdb->prefix . 'ml_eventos';
$charset = $wpdb->get_charset_collate();
// ATENÇÃO à formatação: o dbDelta() faz parsing por regex (veja as regras abaixo).
$sql = "CREATE TABLE $tabela (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
tipo varchar(50) NOT NULL DEFAULT '',
payload longtext NOT NULL,
criado_em datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY user_id (user_id),
KEY tipo_criado (tipo,criado_em)
) $charset;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
// guarda a versão da estrutura para futuras migrações (veja adiante)
update_option( 'minha_loja_db_version', '1.0.0' );
}
Repare em PRIMARY KEY (id): há dois espaços entre KEY e o parêntese. Isso não é erro de digitação — é uma das regras de formatação do dbDelta().
As regras de formatação do dbDelta()
O dbDelta() não executa o seu SQL diretamente: ele compara a estrutura descrita com a tabela existente e emite apenas os ALTER necessários — é o que permite usar a mesma função para criar e para migrar. Esse diff é feito com expressões regulares que esperam um formato muito específico. Quebre-o e o dbDelta() passa a recriar índices a cada execução, ou ignora colunas. Siga estas regras:
| Regra | Por quê |
|---|---|
| Cada campo/chave em uma linha própria | O parser divide a definição por quebras de linha |
Dois espaços entre PRIMARY KEY e o parêntese: PRIMARY KEY (id) | É o padrão que a regex do diff espera |
Use KEY, não INDEX | dbDelta() só reconhece a palavra KEY para índices |
Dê um nome a cada KEY (ex.: KEY user_id (user_id)) | Sem nome, o diff não consegue rastrear o índice entre execuções |
| Tipos e palavras-chave em caixa consistente; sem espaço supérfluo | A comparação textual é sensível a variações de formatação |
Use CREATE TABLE (sem IF NOT EXISTS) | O próprio dbDelta() decide criar ou alterar; IF NOT EXISTS atrapalha o diff |
Aplique o $wpdb->get_charset_collate() ao final | Garante utf8mb4 e collation consistentes |
O sintoma clássico de formatação errada é o dbDelta() tentar recriar a mesma chave a cada carga (visível ativando SAVEQUERIES ou logando o retorno da função, que é um array do que ele alterou). Se a sua rotina de upgrade roda em todo admin_init e você vê ALTER TABLE acontecendo sempre, quase certamente é o espaçamento do PRIMARY KEY ou um INDEX no lugar de KEY.
O hook de ativação não roda em atualizações
Aqui está a armadilha que pega quase todo mundo na segunda versão do plugin. O register_activation_hook() dispara somente quando o plugin é ativado manualmente. Atualizar o plugin — pelo painel, por WP-CLI ou por upload — não reativa, então o hook de ativação não roda. Resultado: se a versão 2.0 adiciona uma coluna, quem já tinha a 1.0 ativa nunca recebe a alteração.
A solução padrão é versionar a estrutura do banco em uma option e checar, em um hook que roda sempre, se a versão salva está defasada:
<?php
define( 'MINHA_LOJA_DB_VERSION', '1.1.0' ); // suba este número ao mudar o schema
add_action( 'plugins_loaded', 'minha_loja_verificar_db' );
function minha_loja_verificar_db() {
$instalada = get_option( 'minha_loja_db_version' );
if ( $instalada === MINHA_LOJA_DB_VERSION ) {
return; // já está atualizado, nada a fazer
}
// Reaproveita a MESMA função de criação: dbDelta() aplica só o diff
// (ex.: adiciona a coluna nova sem tocar nos dados existentes).
minha_loja_criar_tabela();
update_option( 'minha_loja_db_version', MINHA_LOJA_DB_VERSION );
}
Por que plugins_loaded e não admin_init? Ambos funcionam, mas há um trade-off. O admin_init só roda no painel, então a migração só acontece quando um admin acessa o wp-admin — o que é suficiente e evita o custo em cada requisição do front-end. Já o plugins_loaded garante que a checagem ocorra também em contextos sem admin (REST, cron). A comparação por igualdade de versão é barata (uma leitura de option, geralmente em cache), então rodar em plugins_loaded é aceitável; se quiser o mínimo de overhead, mova para admin_init.
Inserindo dados com segurança: $wpdb->insert()
Para escrever, prefira os métodos insert(), update() e delete() do $wpdb a montar SQL à mão. Você passa um array de dados e um array de formatos (%s, %d, %f), e o $wpdb escapa cada valor no contexto correto — eliminando o risco de SQL Injection nesse caminho:
<?php
global $wpdb;
$tabela = $wpdb->prefix . 'ml_eventos';
$ok = $wpdb->insert(
$tabela,
array( // dados (coluna => valor)
'user_id' => get_current_user_id(),
'tipo' => 'login',
'payload' => wp_json_encode( array( 'ip' => $ip ) ),
'criado_em' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s', '%s' ) // formatos, na MESMA ordem
);
if ( false === $ok ) {
// $wpdb->insert() devolve false em erro; o número de linhas em sucesso
error_log( 'Falha ao inserir evento: ' . $wpdb->last_error );
} else {
$novo_id = $wpdb->insert_id; // id auto-incrementado do registro criado
}
O array de formatos é o que torna a operação segura: declare %d para inteiros, %f para decimais e %s para o resto. update() e delete() seguem a mesma ideia, com um array adicional de WHERE e seus próprios formatos.
Lendo dados: SELECT com $wpdb->prepare()
Os métodos insert()/update()/delete() cobrem a escrita, mas para ler você escreve SQL — e aí entra a regra de ouro: qualquer valor variável passa por $wpdb->prepare(). Nunca concatene variáveis na string da query, nem mesmo um ID inteiro:
<?php
global $wpdb;
$tabela = $wpdb->prefix . 'ml_eventos';
$user_id = get_current_user_id();
$tipo = 'login';
// CERTO: prepare() com placeholders; o nome da tabela entra fora do prepare
// (não é um valor; valide-o você mesmo, aqui ele vem do prefix, é confiável).
$linhas = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, tipo, criado_em
FROM {$tabela}
WHERE user_id = %d AND tipo = %s
ORDER BY criado_em DESC
LIMIT %d",
$user_id,
$tipo,
20
)
);
// ERRADO: concatenar o valor abre brecha de SQL Injection
// $linhas = $wpdb->get_results( "SELECT * FROM {$tabela} WHERE user_id = " . $_GET['u'] );
Detalhe importante: o prepare() trata valores, não identificadores. O nome da tabela e os nomes de colunas não são placeholders — eles entram na string diretamente. Por isso o nome da tabela deve vir de fonte confiável (o $wpdb->prefix + um literal seu), nunca de entrada do usuário. Se precisar de uma coluna dinâmica vinda do exterior, valide-a contra uma allowlist antes de interpolar. Aprofunde em segurança no código.
Performance e cache
Uma tabela própria não passa automaticamente pelo cache de objeto do WordPress — diferentemente de get_post() ou get_option(). Consultas pesadas e repetidas devem ser memorizadas por você, tipicamente com a API de transients ou de cache de objeto:
<?php
function minha_loja_eventos_recentes( $user_id ) {
$chave = 'ml_eventos_' . (int) $user_id;
$dados = wp_cache_get( $chave, 'minha_loja' );
if ( false === $dados ) {
global $wpdb;
$tabela = $wpdb->prefix . 'ml_eventos';
$dados = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$tabela} WHERE user_id = %d ORDER BY criado_em DESC LIMIT 20", $user_id )
);
wp_cache_set( $chave, $dados, 'minha_loja', 5 * MINUTE_IN_SECONDS );
}
return $dados;
}
Lembre de invalidar a chave (wp_cache_delete()) sempre que inserir ou atualizar registros daquele usuário — cache servido sem invalidação é dado velho disfarçado de bug.
Armadilhas comuns
| Sintoma | Causa provável | Correção |
|---|---|---|
Call to undefined function dbDelta() | Faltou incluir upgrade.php | require_once ABSPATH . 'wp-admin/includes/upgrade.php'; |
| Índice recriado a cada carga | Espaçamento do PRIMARY KEY ou INDEX em vez de KEY | Siga as regras de formatação do dbDelta() |
| Tabela não muda ao atualizar o plugin | Hook de ativação não roda no update | Rotina de upgrade por option de versão |
| Quebra em Multisite ou prefixo customizado | Nome da tabela com wp_ fixo | Use sempre $wpdb->prefix |
| Acentos/emojis corrompidos | Charset não herdado | $wpdb->get_charset_collate() no CREATE |
| SQL Injection / valores escapados errado | Variável concatenada na query | $wpdb->prepare() ou os formatos do insert() |
Boas práticas
- Esgote as alternativas primeiro. CPT, post meta e options resolvem a maioria dos casos com muito menos manutenção.
- Versione o schema em uma option e tenha uma rotina de upgrade idempotente baseada em
dbDelta(). - Centralize o nome da tabela em uma única função/constante montada com
$wpdb->prefix, para não repetir o literal por todo o código. - Prefira
insert()/update()/delete()com formatos; reserve SQL manual (sempre viaprepare()) para leituras. - Defina índices conscientemente: crie
KEYpara as colunas que você realmente filtra e ordena — não mais, não menos. - Trate a desinstalação com
register_uninstall_hook()/uninstall.phpe, de preferência, dê ao usuário a opção de preservar os dados.
Tabelas próprias são uma ferramenta de precisão: brilhantes para dados relacionais de alto volume, mas um peso desnecessário quando o WordPress já oferece a estrutura certa. Use o critério, respeite as regras do dbDelta() e versione as migrações — e a sua tabela vai se comportar tão bem quanto as do próprio núcleo. Para o restante da fundação, veja como estruturar tudo isso dentro de um plugin próprio.
Perguntas frequentes
Quando devo criar uma tabela própria em vez de usar post meta ou um CPT?
Crie uma tabela própria quando os dados são relacionais, de alto volume e consultados por colunas específicas — logs, registros de transações, relacionamentos muitos-para-muitos, séries temporais. Esses casos sofrem com a estrutura chave-valor da wp_postmeta, que vira gargalo em JOINs e filtros. Para dados que são "conteúdo" (têm título, autor, status, aparecem em listagens, precisam de revisões), o Custom Post Type é quase sempre a escolha certa, porque você herda admin, REST API, permissões e cache de graça. Para um punhado de configurações globais, use a Options API. A pergunta-chave: "isto é conteúdo editorial ou são dados de aplicação?"
Por que o dbDelta() é tão exigente com a formatação do SQL?
Porque ele não executa o SQL cegamente: o dbDelta() compara a estrutura que você descreve com a tabela que já existe no banco e gera apenas os ALTER TABLE necessários. Para isso ele faz um parsing do seu CREATE TABLE com expressões regulares relativamente rígidas. Se a formatação fugir do esperado — campo fora de sua própria linha, espaçamento errado em PRIMARY KEY, uso de INDEX em vez de KEY — ele interpreta mal a definição e pode recriar índices a cada execução ou silenciosamente ignorar colunas. Seguir as regras à risca não é preciosismo: é o que faz o diff funcionar.
Por que minha tabela não é criada quando o plugin é atualizado?
Porque o register_activation_hook() dispara apenas na ativação manual do plugin — e atualizar um plugin (pelo painel ou via WP-CLI) não reativa, logo o hook de ativação não roda. Se a versão 2.0 adiciona uma coluna, quem já tinha a 1.0 instalada nunca veria a mudança. A solução padrão é guardar uma "db version" em uma option e, em um hook que roda sempre (como plugins_loaded ou admin_init), comparar a versão salva com a atual; se diferirem, rodar o dbDelta() de novo e atualizar a option.
Preciso usar $wpdb->prepare() mesmo em consultas que parecem seguras?
Sim, sempre que houver qualquer valor variável na cláusula — mesmo um ID que "veio do banco" ou um inteiro que você acha que controla. Concatenar variáveis na string SQL é a porta de entrada do SQL Injection. Use $wpdb->prepare() com placeholders (%s, %d, %f) para a parte de valores. Para insert(), update() e delete(), o próprio $wpdb já escapa os valores quando você passa o array de formatos — então prefira esses métodos a montar SQL à mão. Veja o guia de segurança no código.
O que é o $wpdb->prefix e por que não posso escrever "wp_" na mão?
O $wpdb->prefix é o prefixo configurado para as tabelas daquela instalação — definido em $table_prefix no wp-config.php. O padrão é wp_, mas muita gente altera por segurança, e em Multisite cada subsite tem seu próprio prefixo numerado (wp_2_, wp_3_…). Escrever wp_minha_tabela fixo quebra em qualquer instalação que não use o prefixo padrão. Sempre componha o nome com $wpdb->prefix . 'minha_tabela'.
Devo apagar a tabela quando o plugin é desinstalado?
Depende da política do produto, mas a desinstalação (não a desativação) é o lugar certo para isso, via register_uninstall_hook() ou um arquivo uninstall.php. Cuidado: desativar um plugin não deve apagar dados do usuário — só a desinstalação explícita deveria. E ofereça uma opção de "manter dados ao desinstalar", porque remover uma tabela com o histórico do cliente sem aviso é uma forma rápida de perder a confiança dele. Faça DROP TABLE com o nome sempre montado a partir do $wpdb->prefix.