DesenvolvedorWordPress WhatsApp

Tabelas customizadas no WordPress com dbDelta()

Nem todo dado cabe em um post ou em uma option. Quando você tem informação relacional e de alto volume, uma tabela própria é a resposta — mas criá-la corretamente no WordPress tem regras pouco intuitivas. Este guia mostra quando (e quando não) usar uma tabela própria, como o dbDelta() exige que você formate o SQL, por que o hook de ativação não basta e como fazer CRUD com segurança.

Precisa de uma estrutura de dados sob medida?

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:

AbordagemUse quandoEvite quando
Options API (wp_options)Poucas configurações globais do site/pluginDados por usuário/registro, ou que crescem sem limite (autoload pesa)
Post meta (wp_postmeta)Atributos extras de um post existente, poucos por postFiltrar/ordenar por esses valores em grande volume (JOINs caros)
Custom Post TypeOs dados são "conteúdo": têm autor, status, listagem, aparecem no adminSão dados de aplicação puros, sem cara de conteúdo editorial
Tabela própriaDados 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:

RegraPor quê
Cada campo/chave em uma linha própriaO 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 INDEXdbDelta() 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érfluoA 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 finalGarante 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

SintomaCausa provávelCorreção
Call to undefined function dbDelta()Faltou incluir upgrade.phprequire_once ABSPATH . 'wp-admin/includes/upgrade.php';
Índice recriado a cada cargaEspaçamento do PRIMARY KEY ou INDEX em vez de KEYSiga as regras de formatação do dbDelta()
Tabela não muda ao atualizar o pluginHook de ativação não roda no updateRotina de upgrade por option de versão
Quebra em Multisite ou prefixo customizadoNome da tabela com wp_ fixoUse sempre $wpdb->prefix
Acentos/emojis corrompidosCharset não herdado$wpdb->get_charset_collate() no CREATE
SQL Injection / valores escapados erradoVariável concatenada na query$wpdb->prepare() ou os formatos do insert()

Boas práticas

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.

Precisa de uma estrutura de dados robusta no seu WordPress?

Modelo e implemento tabelas customizadas, migrações versionadas e CRUD seguro com $wpdb — ou ajudo a decidir se um CPT resolve com menos manutenção. Me chame no WhatsApp.

WhatsApp: (43) 99932-9697

ou e-mail: [email protected]

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.

Referências oficiais

  1. WordPress.org — Creating Tables with Plugins (Plugin Handbook)
  2. WordPress.org — dbDelta() (Code Reference)
  3. WordPress.org — Classe wpdb (Code Reference)
Tem alguma dúvida? Ficarei feliz em ajudar. Clique Aqui