Convenções PostgreSQL
Como aplicar
Existem dois modos. Decida qual se aplica antes de escrever qualquer coisa.
Modo A — você escolhe os nomes. O usuário pediu um esquema, entidade ou migração e não ditou nomes. Aplique as convenções abaixo diretamente.
Modo B — os nomes já existem ou foram ditados. O usuário especificou nomes, ou código existente, migrações ou o banco de dados contêm nomes que desviam dessas convenções. Nunca renomeie silenciosamente. Relate e pergunte.
Formato de relatório, no idioma do usuário, antes de fazer alterações:
Nomenclatura:
Deve corrigir
- `"UserProfile"` (tabela) → `user_profiles` — PascalCase entre aspas deve ser citado em toda consulta para sempre
- `created` timestamp DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') → `created_at timestamptz DEFAULT now()` — o valor muda quando o TimeZone da sessão não é UTC
Recomendado
- `users.user_email` → `users.email` — o nome da tabela já é o contexto
Então faça uma pergunta: aplicar tudo, aplicar apenas "Deve corrigir", ou manter como está. Aguarde a resposta.
Regras para relatar:
- Deve corrigir = causa bugs, quebra consultas ou ferramentas: identificadores de maiúsculas/minúsculas mistas entre aspas, palavras reservadas, nomes com mais de 63
bytes,
timestampsem fuso horário, defaults de tempo errados,json/float/moneypara dados que precisam dejsonb/numeric, índice FK ausente, locks fora de uma transação. - Recomendado = estilo e consistência.
- Relate apenas objetos tocados pela tarefa atual. Se você notou outros desvios em outro lugar, adicione uma linha: "Também N desvios semelhantes em — listá-los?"
- Objetos em migrações aplicadas ou em um banco de dados ativo: uma renomeação é uma mudança de quebra. Apenas sugira; se aprovado, implemente como uma nova migração. Nunca edite uma migração aplicada.
- Se o codebase usa consistentemente uma convenção diferente, ela vence. Mencione a diferença uma vez, então siga a convenção do projeto sem sinalizar cada ocorrência.
- Nomes produzidos pela convenção de nomenclatura do ORM (por exemplo, EFCore.NamingConventions) são aceitos como estão.
- Uma vez que o usuário confirme um nome não conforme, use-o e não o levante novamente nesta sessão.
Regras gerais
snake_case, minúsculo, nunca identificadores entre aspas.- Máximo de 63 bytes por identificador; nomes mais longos são truncados silenciosamente. Encurte de forma significativa, não cortando.
- Tabelas no plural:
users,orders,order_items. - Colunas no singular, sem prefixo de tabela:
users.email, nãousers.user_email. - Palavras completas, exceto abreviações comuns:
id,url,ip,sku. - Nunca use palavras reservadas ou ambíguas como nomes:
user,order,group,table,limit,offset,desc,end,date,time,timestamp. - Sem prefixos de tipo ou objeto: sem
tbl_users,str_name. - Booleanos são positivos:
is_deleted, nuncais_not_deleted.
Colunas padrão
| Propósito | Nome | Tipo |
|---|---|---|
| Chave primária | id | bigint generated always as identity ou uuid v7 |
| Criado / atualizado | created_at, updated_at | timestamptz not null default now() |
| Exclusão lógica | deleted_at | timestamptz null |
| Auditoria | created_by, updated_by | FK para users.id |
| Flags | is_active, is_verified, has_children, can_edit | boolean not null default false |
| Status | status | text + check, enum, ou tabela de consulta |
| Apenas data | birth_date, due_on | date |
| Validade | expires_at, valid_from, valid_to | timestamptz |
| Dinheiro | amount, unit_price, total_amount + currency_code | numeric(19,4) + char(3), ou amount_minor bigint |
| Contadores | items_count, retry_count | integer / bigint |
| Ordenação | sort_order | integer |
| Chave de negócio | code, slug, external_id | text + único |
| Locatário | tenant_id | FK |
| Concorrência | version ou sistema xmin | integer / xid |
| Dados livres | metadata, payload, settings | jsonb |
Sufixos carregam significado: _at instante, _on/_date data de calendário, _id referência, _count contador, _url,
_path, _hash.
Chaves e restrições
| Objeto | Padrão | Exemplo |
|---|---|---|
| Chave primária | pk_<tabela> | pk_orders |
| Chave estrangeira | fk_<tabela>_<tabela_ref>_<coluna> | fk_orders_customers_customer_id |
| Restrição única | uq_<tabela>_<cols> | uq_users_email |
| Chave alternativa (EF Core) | ak_<tabela>_<cols> | ak_products_sku |
| Check | ck_<tabela>_<regra> | ck_orders_amount_positive |
| Exclusão | ex_<tabela>_<cols> | ex_bookings_room_id_period |
| Índice | ix_<tabela>_<cols> | ix_orders_customer_id_created_at |
| Índice único | ux_<tabela>_<cols> | ux_users_email |
| Índice parcial | ix_<tabela>_<cols>_<condição> | ix_users_email_not_deleted |
| Índice por método | ix_<tabela>_<col>_<método> | ix_products_tags_gin |
| Gatilho | trg_<tabela>_<ação> | trg_orders_set_updated_at |
| Função de gatilho | verbo + objeto | set_updated_at() |
| Sequência | Padrão PostgreSQL | orders_id_seq |
- Coluna FK:
<tabela referenciada, singular>_id, ou um nome de papel quando o papel importa:author_id,sender_id,recipient_id,parent_id. - O padrão FK inclui a coluna, então várias FKs para uma tabela permanecem únicas:
fk_messages_users_sender_id,fk_messages_users_recipient_id. - PostgreSQL não indexa colunas FK. Toda FK precisa de um índice, a menos que um índice composto já comece com ela. EF Core cria esses por convenção; Drizzle, Prisma, TypeORM, Knex e sqlc não criam.
Relações
| Tipo | Forma | Exemplo |
|---|---|---|
| Um-para-muitos | FK no filho | order_items.order_id → orders.id |
| Um-para-um | FK + única, ou PK = FK | user_profiles.user_id |
| Muitos-para-muitos | Junção <a>_<b>, PK composta | user_roles (user_id, role_id), pk_user_roles |
| Muitos-para-muitos com atributos | Nome de entidade significativa | memberships, enrollments |
| Auto-referência | parent_id ou papel | categories.parent_id, employees.manager_id |
Pares polimórficos (commentable_type + commentable_id) não podem ter FKs. Prefira colunas FK nulas separadas com um
check que exatamente uma está definida, ou tabelas separadas.
Esquemas, visões, funções, tipos
| Objeto | Padrão | Exemplo |
|---|---|---|
| Esquema | domínio ou módulo | auth, billing, catalog, audit |
| Visão | v_<nome> | v_active_users |
| Visão materializada | mv_<nome> | mv_daily_sales |
| Função / procedimento | verbo + objeto | get_user_balance, calculate_order_total |
| Tipo enum | <entidade>_<atributo> | order_status |
| Valores enum | snake_case minúsculo | pending, paid, refunded |
| Papel do banco | <app>_<acesso> | shop_app_rw, shop_app_ro, shop_migrator |
Valores de enum não podem ser removidos sem recriar o tipo. Para conjuntos que mudam, prefira text + check ou uma tabela
de consulta.
Tipos e defaults
timestamptzpara instantes, defaultnow().CURRENT_TIMESTAMP AT TIME ZONE 'UTC'em uma colunatimestamptzé um bug.textem vez devarchar(n)a menos que o comprimento seja uma regra de negócio; prefiracheck (char_length(col) <= n)para isso.jsonb, nuncajson.numericpara dinheiro; nuncareal,double precisionoumoney.- Novas colunas de identidade usam
generated always as identity, nãoserial/bigserial. - Chaves primárias UUID usam v7: PostgreSQL 18
uuidv7(), .NET 9Guid.CreateVersion7(), Nodeuuidpackagev7(), Gogithub.com/google/uuiduuid.NewV7(). Chaves v4 aleatórias fragmentam índices B-tree. gen_random_uuid()é embutido desde PostgreSQL 13;uuid-osspepgcryptonão são necessários para isso.citextou um índice únicolower(email)para unicidade insensível a maiúsculas/minúsculas.
Concorrência e padrões de consulta
- Concorrência otimista, EF Core + Npgsql: uma propriedade
uintconfigurada comIsRowVersion()mapeia paraxmin. - Concorrência otimista, Node e Go:
version integer not null default 0, atualizado comwhere id = $1 and version = $2eversion = version + 1; zero linhas afetadas significa um conflito. No sqlc use:execrowspara obter a contagem de linhas afetadas. select ... for updateefor update skip lockedapenas dentro de uma transação explícita; em autocommit o lock é liberado imediatamente.- Locks de aviso:
pg_try_advisory_xact_lockdentro de uma transação.pg_advisory_lockem nível de sessão apenas em uma conexão dedicada mantida aberta até o desbloqueio; com pool, o lock e o unlock podem cair em conexões diferentes. - Listas grandes ou infinitas usam paginação por chave:
where (created_at, id) < ($1, $2) order by created_at desc, id desc limit $3.offsetapenas para listas pequenas e limitadas. - Concessões para tabelas futuras precisam de
alter default privileges, não apenasgrant ... on all tables.
Configuração ORM para snake_case
| Pilha | Configuração |
|---|---|
| EF Core + Npgsql | EFCore.NamingConventions, UseSnakeCaseNamingConvention() |
| Drizzle | casing: 'snake_case' |
| Prisma | @map nos campos, @@map nos modelos |
| TypeORM | SnakeNamingStrategy de typeorm-naming-strategies |
| Knex / Objection | knexSnakeCaseMappers() |
| Go + sqlc | nenhuma; nomes Go são gerados de snake_case automaticamente |
Se um projeto usa um ORM sem snake_case configurado, relate como Deve corrigir antes da primeira migração; após migrações existirem, relate como Recomendado com o custo da migração.
Go + sqlc
sqlc é SQL-first: o esquema vem dos arquivos de migração, então toda regra acima se aplica diretamente a eles.
- Aponte
schemaemsqlc.yamlpara o diretório de migrações (formatos goose, golang-migrate, dbmate e atlas são suportados; migrações down são ignoradas). Nunca mantenha um arquivo de esquema separado escrito à mão ao lado das migrações. - Tabelas no plural snake_case geram structs Go no singular:
order_items→OrderItem. Enumorder_status→ tipoOrderStatuscom constantesOrderStatusPending. - Use
sql_package: pgx/v5. Sobrescrevauuid→github.com/google/uuid.UUIDe, se desejado,timestamptz→time.Timeem vez dos wrapperspgtype. - Corrija inicialismos com
rename(url: URL,ip: IP) em vez de renomear colunas. - Nomes de consultas são PascalCase verbo + objeto, com o tipo de resultado:
| Propósito | Exemplo |
|---|---|
| Uma linha | -- name: GetUserByEmail :one |
| Muitas linhas | -- name: ListOrdersByCustomer :many |
| Inserir | -- name: CreateOrder :one + returning * |
| Atualizar | -- name: UpdateOrderStatus :execrows |
| Excluir | -- name: DeleteSession :exec |
| Carga em massa | -- name: CreateOrderItems :copyfrom |
- Nomeie parâmetros com
@nameousqlc.arg(name), esqlc.narg(name)para os anuláveis; posicional$1produz campos Go do tipoColumn1. - Use
sqlc.embed(table)para junções em vez de selecionar colunas com aliases ad-hoc. - Nunca use
select *em consultas que devem sobreviver a mudanças de esquema, excetoreturning *em escritas.