Skip to content

@protoutil/protoc-gen-sql

protoc-gen-sql

A protoc plugin that generates SQL schema and CRUD query files from annotated protobuf definitions. Define your entities once in proto, generate schema for multiple database engines from a single source of truth.

Installation

Terminal window
npm install -g @protoutil/protoc-gen-sql

Add the options proto as a dependency in your buf.yaml:

deps:
- buf.build/protoutil/sql

Quick start

1. Annotate your proto messages

syntax = "proto3";
package myapp.v1;
import "protoutil/sql/v1/options.proto";
message User {
option (protoutil.sql.v1.message) = {
generate: true
indexes: ["email"]
};
string name = 1 [(protoutil.sql.v1.field).omit = true]; // derived at app layer
string email = 2 [(protoutil.sql.v1.field).unique = true];
string bio = 3 [(protoutil.sql.v1.field).nullable = true];
bool active = 4;
}

2. Configure buf.gen.yaml

version: v2
inputs:
- directory: proto
plugins:
- local: protoc-gen-sql
out: generated/postgres
opt:
- engine=postgres
- local: protoc-gen-sql
out: generated/sqlite
opt:
- engine=sqlite

3. Generate

Terminal window
buf generate

This produces:

generated/
├── postgres/
│ ├── schema.sql
│ └── queries/generated/
│ └── myapp_v1_users.sql
└── sqlite/
├── schema.sql
└── queries/generated/
└── myapp_v1_users.sql

Plugin options

Passed via the opt: key in buf.gen.yaml. Each invocation targets one engine.

OptionRequiredValuesDefaultDescription
enginepostgres mysql sqlite sqlserverTarget database engine
repeated_strategyjson_column array_columnjson_columnHow repeated fields are stored. array_column requires Postgres; falls back to json_column on other engines
oneof_strategynullable_columns type_column json_columnnullable_columnsDefault strategy for oneof groups. Can be overridden per-oneof via OneofOptions
if_not_existstrue falsetrueEmit CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS. Set to false only if your migration tooling manages existence checks itself
emit_create_schematrue falsefalseEmit CREATE SCHEMA IF NOT EXISTS at the top of schema.sql. Only meaningful for postgres and sqlserver

Proto options reference

MessageOptions (protoutil.sql.v1.message)

Controls table-level generation for a message. Only messages with generate = true produce any output — all others are silently ignored.

FieldTypeDefaultDescription
generateboolfalseOpt this message into generation. Must be true to produce any output
table_namestringFQN snake_caseOverride the generated table name. Default: library.v1.Booklibrary_v1_book
indexesrepeated string[]Composite indexes as comma-separated column names. e.g. ["author,title"]
unique_constraintsrepeated string[]Composite unique constraints as comma-separated column names
skip_queriesrepeated QueryType[]Suppress specific generated queries. See QueryType enum
foreign_keysrepeated ForeignKey[]Explicit FK column declarations. See ForeignKey message
timestampsTimestampBehaviorBOTHControls created_at / updated_at injection. See TimestampBehavior enum
extra_columnsrepeated FieldOptions[]Additional columns with no proto field. See extra columns section
primary_keyPrimaryKeyTypePRIMARY_KEY_TYPE_SERIALStorage type for the id primary key column. See PrimaryKeyType enum

FieldOptions (protoutil.sql.v1.field)

Controls column-level generation for a proto field. Also used as the element type for extra_columns.

FieldTypeDefaultDescription
column_namestringfield nameOverride the column name. Required in extra_columns
column_typestringengine defaultOverride the SQL type for all engines. Acts as a fallback when type_overrides has no entry for the target engine. Required in extra_columns unless type_overrides covers every engine you generate for
type_overridesmap<string, string>{}Per-engine type overrides. Keys must be valid engine names: postgres mysql sqlite sqlserver. Takes precedence over column_type. Validation: [V15] rejects unrecognised keys. See type_overrides usage
nullableboolfalseEmit NULL instead of NOT NULL
defaultstringSQL DEFAULT expression, written verbatim. e.g. "now()", "''", "0"
indexboolfalseAdd a single-column index. For composite indexes use MessageOptions.indexes
uniqueboolfalseAdd a UNIQUE constraint
omitboolfalseExclude this field entirely. Must not be true in extra_columns
enum_strategyEnumStrategyENUM_STRATEGY_NAMEHow to store a proto enum field. Only meaningful on enum-typed fields
checkstringInline CHECK constraint, written verbatim. e.g. "price_cents > 0"
skip_queriesrepeated QueryType[]Suppress this column from specific generated queries. Applies to both proto-derived fields and extra_columns. e.g. (protoutil.sql.v1.field).skip_queries = QUERY_TYPE_GET, (protoutil.sql.v1.field).skip_queries = QUERY_TYPE_LIST to exclude a sensitive field from reads, or (protoutil.sql.v1.field).skip_queries = QUERY_TYPE_CREATE, (protoutil.sql.v1.field).skip_queries = QUERY_TYPE_UPDATE to exclude a DB-managed column from writes. See skip_queries usage

OneofOptions (sql.oneof)

Applied to the oneof declaration itself, not individual fields within it.

FieldTypeDefaultDescription
strategyOneofStrategyplugin optOverride the global oneof_strategy for this group
skip_queriesrepeated QueryType[]Suppress this oneof’s columns from specific generated queries. Same values and semantics as FieldOptions.skip_queries. See skip_queries usage

ForeignKey

Declares an explicit FK column on the table. Foreign keys are never inferred.

FieldTypeDefaultDescription
columnstringRequired. Column name on this table. e.g. "shelf_id"
references_tablestringRequired. Table being referenced. e.g. "library_v1_shelf"
references_columnstring"id"Column on the referenced table
on_deleteForeignKeyActionengine defaultCASCADE RESTRICT SET_NULL NO_ACTION
on_updateForeignKeyActionengine defaultCASCADE RESTRICT SET_NULL NO_ACTION
skip_indexboolfalseSet to true to suppress the automatic index on the FK column. Useful when the FK column is already covered by a composite index declared in MessageOptions.indexes — generating a redundant single-column index wastes space and slows writes
skip_queriesrepeated QueryType[]Suppress this FK column from specific generated queries. e.g. skip_queries: [QUERY_TYPE_UPDATE] to make the relationship immutable after creation, or skip_queries: [QUERY_TYPE_GET, QUERY_TYPE_LIST] to hide an internal column from reads. See skip_queries usage

Enums

QueryType

Used in MessageOptions.skip_queries, FieldOptions.skip_queries, OneofOptions.skip_queries, and ForeignKey.skip_queries.

ValueGenerated query
QUERY_TYPE_GETSELECT col1, col2, ... WHERE id = $1 LIMIT 1
QUERY_TYPE_LISTSELECT col1, col2, ... ORDER BY id
QUERY_TYPE_CREATEINSERT INTO ...
QUERY_TYPE_UPDATEUPDATE ... SET ... WHERE id = $1
QUERY_TYPE_DELETEDELETE ... WHERE id = $1
QUERY_TYPE_ALLSuppresses all query generation for this message; schema DDL is still produced

TimestampBehavior

Used in MessageOptions.timestamps.

Valuecreated_atupdated_at
TIMESTAMP_BEHAVIOR_UNSPECIFIED / TIMESTAMP_BEHAVIOR_BOTH✅ injected✅ injected
TIMESTAMP_BEHAVIOR_CREATED_ONLY✅ injected❌ omitted
TIMESTAMP_BEHAVIOR_UPDATED_ONLY❌ omitted✅ injected
TIMESTAMP_BEHAVIOR_NONE❌ omitted❌ omitted

OneofStrategy

ValueBehavior
ONEOF_STRATEGY_NULLABLE_COLSOne nullable column per variant + CHECK constraint ensuring at most one is non-null
ONEOF_STRATEGY_TYPE_COLUMNDiscriminator column ({name}_type TEXT) + single value column ({name}_value TEXT)
ONEOF_STRATEGY_JSON_COLUMNEntire oneof serialized to a single JSONB / JSON / TEXT column

EnumStrategy

ValueBehavior
ENUM_STRATEGY_NAME (default)Store the enum value name as TEXT
ENUM_STRATEGY_INTStore the integer value as INTEGER
ENUM_STRATEGY_CHECK_CONSTRAINTStore as TEXT with a CHECK constraint listing all valid value names. The column identifier in the CHECK expression is quoted using the engine’s native quoting style (backticks on MySQL, double-quotes elsewhere)
ENUM_STRATEGY_NATIVE_TYPEUse a native database enum type. Postgres only (CREATE TYPE ... AS ENUM); falls back to ENUM_STRATEGY_NAME on other engines

PrimaryKeyType

ValueBehavior
PRIMARY_KEY_TYPE_SERIAL (default)Engine serial integer: BIGSERIAL (postgres), BIGINT UNSIGNED AUTO_INCREMENT (mysql), INTEGER (sqlite), BIGINT IDENTITY(1,1) (sqlserver)
PRIMARY_KEY_TYPE_UUIDDatabase-generated UUID: UUID DEFAULT gen_random_uuid() (postgres), CHAR(36) DEFAULT (UUID()) (mysql), TEXT DEFAULT (lower(hex(randomblob(16)))) (sqlite), UNIQUEIDENTIFIER DEFAULT NEWID() (sqlserver). FK columns referencing a UUID table automatically use the matching UUID type

Extra columns

extra_columns injects columns that have no corresponding proto field — useful for columns managed entirely at the database layer.

message Order {
option (protoutil.sql.v1.message) = {
generate: true
extra_columns: [
{
column_name: "deleted_at"
column_type: "TIMESTAMPTZ"
nullable: true
index: true
},
{
column_name: "row_version"
column_type: "BIGINT"
default: "1"
skip_queries: [QUERY_TYPE_CREATE, QUERY_TYPE_UPDATE] // managed by DB trigger
}
]
};
string reference = 1;
}

Extra columns appear after proto-derived fields and before timestamp columns.

By default, extra columns are included in SELECT, INSERT, and UPDATE. Use skip_queries to exclude specific columns from specific query types — see skip_queries usage for examples.


type_overrides usage

type_overrides lets you specify a different SQL type for each engine without changing the proto field type. Because the same proto is typically run through the plugin once per engine (via separate buf.gen.yaml plugin invocations), you can cover all engines without needing a column_type fallback:

// Cover every engine explicitly — no column_type needed
bool active = 1 [
(protoutil.sql.v1.field).type_overrides = { key: "postgres" value: "BOOLEAN" },
(protoutil.sql.v1.field).type_overrides = { key: "mysql" value: "TINYINT(1)" },
(protoutil.sql.v1.field).type_overrides = { key: "sqlite" value: "INTEGER" },
(protoutil.sql.v1.field).type_overrides = { key: "sqlserver" value: "BIT" }
];

Or use column_type as a universal fallback and only override where the default differs:

// column_type is the fallback; sqlite gets a specific override
bool active = 1 [(protoutil.sql.v1.field) = {
column_type: "BOOLEAN"
type_overrides: { key: "sqlite" value: "INTEGER" }
}];

The same applies to extra_columns. [V06] will fire for any engine that has neither a matching type_overrides key nor a column_type fallback, so if you only provide a sqlite override and generate for postgres without column_type, you will get a validation error on the postgres run.


skip_index usage

By default, every FK column gets an automatic single-column index. Set skip_index: true to suppress it — typically when the FK column is already the leading column (or an early column) in a composite index you have declared in MessageOptions.indexes, making the single-column index redundant:

message Book {
option (protoutil.sql.v1.message) = {
generate: true
// Composite index already covers shelf_id — single-column index is redundant
indexes: ["shelf_id,created_at"]
foreign_keys: [{
column: "shelf_id"
references_table: "library_v1_shelf"
skip_index: true
}]
};
string title = 1;
}

skip_queries usage

skip_queries suppresses a column from specific generated query types. It is available on FieldOptions, OneofOptions, and ForeignKey. Because skip_queries is a repeated field, each value must be specified as a separate option annotation.

Exclude a sensitive field from reads (GET and LIST):

message User {
option (protoutil.sql.v1.message).generate = true;
string email = 1;
string password_hash = 2 [
(protoutil.sql.v1.field).skip_queries = QUERY_TYPE_GET,
(protoutil.sql.v1.field).skip_queries = QUERY_TYPE_LIST
];
}

password_hash is included in INSERT and UPDATE but never appears in SELECT column lists.

Exclude a DB-managed column from writes (CREATE and UPDATE):

message Document {
option (protoutil.sql.v1.message).generate = true;
string body = 1;
string search_vec = 2 [
(protoutil.sql.v1.field).skip_queries = QUERY_TYPE_CREATE,
(protoutil.sql.v1.field).skip_queries = QUERY_TYPE_UPDATE
];
}

search_vec appears in SELECT but is never written by generated queries — a trigger or generated column expression manages it.

Make an FK relationship immutable after creation:

message Book {
option (protoutil.sql.v1.message) = {
generate: true
foreign_keys: [{
column: "shelf_id"
references_table: "library_v1_shelf"
skip_queries: [QUERY_TYPE_UPDATE]
}]
};
string title = 1;
}

shelf_id is written on INSERT but excluded from UPDATE SET.

Suppress an entire oneof from all queries:

message Book {
option (protoutil.sql.v1.message).generate = true;
string title = 1;
oneof contact {
option (protoutil.sql.v1.oneof).skip_queries = QUERY_TYPE_CREATE;
option (protoutil.sql.v1.oneof).skip_queries = QUERY_TYPE_UPDATE;
option (protoutil.sql.v1.oneof).skip_queries = QUERY_TYPE_GET;
option (protoutil.sql.v1.oneof).skip_queries = QUERY_TYPE_LIST;
string email = 2;
string phone = 3;
}
}

QUERY_TYPE_DELETE and QUERY_TYPE_ALL have no effect at the column level.


Type mapping

Default proto scalar → SQL type per engine. Override per-field with column_type or type_overrides.

Proto typepostgresmysqlsqlitesqlserver
stringTEXTTEXTTEXTNVARCHAR(MAX)
boolBOOLEANTINYINT(1)INTEGERBIT
int32INTEGERINTINTEGERINT
int64BIGINTBIGINTINTEGERBIGINT
uint32BIGINTBIGINT UNSIGNEDINTEGERBIGINT
uint64NUMERIC(20,0)BIGINT UNSIGNEDINTEGERDECIMAL(20,0)
floatREALFLOATREALREAL
doubleDOUBLE PRECISIONDOUBLEREALFLOAT
bytesBYTEABLOBBLOBVARBINARY(MAX)
sint32INTEGERINTINTEGERINT
sint64BIGINTBIGINTINTEGERBIGINT
fixed32BIGINTBIGINT UNSIGNEDINTEGERBIGINT
fixed64NUMERIC(20,0)BIGINT UNSIGNEDINTEGERDECIMAL(20,0)
sfixed32INTEGERINTINTEGERINT
sfixed64BIGINTBIGINTINTEGERBIGINT

Well-known types

Proto typepostgresmysqlsqlitesqlserver
google.protobuf.TimestampTIMESTAMPTZDATETIME(6)TEXTDATETIMEOFFSET
google.protobuf.DurationBIGINTBIGINTINTEGERBIGINT
google.protobuf.StructJSONBJSONTEXTNVARCHAR(MAX)
google.protobuf.ValueJSONBJSONTEXTNVARCHAR(MAX)
google.protobuf.ListValueJSONBJSONTEXTNVARCHAR(MAX)
google.protobuf.StringValueTEXTTEXTTEXTNVARCHAR(MAX)
google.protobuf.BoolValueBOOLEANTINYINT(1)INTEGERBIT
google.protobuf.Int32ValueINTEGERINTINTEGERINT
google.protobuf.Int64ValueBIGINTBIGINTINTEGERBIGINT
google.protobuf.FloatValueREALFLOATREALREAL
google.protobuf.DoubleValueDOUBLE PRECISIONDOUBLEREALFLOAT
google.protobuf.BytesValueBYTEABLOBBLOBVARBINARY(MAX)

Injected columns

These columns are always added by the plugin and cannot be used as proto field names, FK columns, or extra column names unless the relevant injection is disabled.

ColumnType (postgres)Controlled by
idengine-specific serialAlways present; cannot be disabled
created_atTIMESTAMPTZTimestampBehavior
updated_atTIMESTAMPTZTimestampBehavior

Generated queries

For each opted-in message the plugin generates a CRUD query file. The file header includes the target engine so the source of each file is unambiguous when you generate for multiple engines.

Column inclusion in INSERT and UPDATE

The generated INSERT includes, in order:

  1. FK columns (all, unless excluded via skip_queries)
  2. Proto-derived fields (non-omitted, non-oneof)
  3. Oneof columns (all strategies, unless excluded via skip_queries on the oneof)
  4. Extra columns (unless excluded via skip_queries)

The generated UPDATE SET includes the same columns, except:

  • FK columns with skip_queries: [QUERY_TYPE_UPDATE] are excluded
  • Extra columns with skip_queries: [QUERY_TYPE_UPDATE] are excluded
  • Oneof columns with skip_queries: [QUERY_TYPE_UPDATE] are excluded

Validation rules

The plugin validates all annotations before generating any output and reports all errors together. Each error includes a rule code for easy reference.

RuleDescription
V01foreign_key.column must not be empty
V02foreign_key.references_table must not be empty
V03foreign_key.column must not be a reserved column (id, created_at, updated_at) unless the relevant injection is disabled via timestamps
V04foreign_key.column must not duplicate another column in the table
V05extra_columns entry must have a non-empty column_name
V06extra_columns entry must have a non-empty column_type, or a type_overrides entry whose key matches the engine currently being generated for. Because the same proto is typically run through the plugin once per engine, an entry that only covers sqlite will pass when generating for sqlite but fail when generating for postgres — unless column_type is also set as a universal fallback
V07extra_columns entry must not have omit = true
V08extra_columns entry column_name must not be a reserved column
V09extra_columns entry column_name must not duplicate another column
V10Column names referenced in MessageOptions.indexes must exist in the table
V11Column names referenced in MessageOptions.unique_constraints must exist in the table
V12A proto field’s column_name override must not be a reserved column
V13A proto field’s resolved column name must not duplicate another column
V14ENUM_STRATEGY_NATIVE_TYPE requires engine=postgres. Error on all other engines
V15type_overrides keys must be valid engine names (postgres, mysql, sqlite, sqlserver). Unrecognised keys (e.g. typos like "postgress" or wrong casing like "Postgres") are rejected at validation time

Table naming convention

By default, table names are derived from the fully-qualified proto message name:

  • Dots replaced with underscores
  • Converted to snake_case

Examples:

Proto messageDefault table name
library.v1.Booklibrary_v1_book
library.v1.Shelflibrary_v1_shelf
myapp.v2.UserProfilemyapp_v2_user_profile

This ensures table names are versioned alongside the proto package and never silently collide when you introduce a v2 of a package. Override with table_name if needed.