On this page
MySQL
Select the dialect with dialect: mysql in sgl.yaml, or dialect_registry.New("mysql", ...) from Go.
Capability summary
| Concern | Contract |
|---|---|
| Parser family | MySQL |
| Generated API | database/sql |
| Live introspection | No built-in source |
| Runtime migrations | Non-transactional migrations and implicit DDL commits. Lock keys are scoped to the current schema unless compatibility options change them. |
Known limitations records inference gaps and workarounds; the driver matrix compares generated APIs.
The engine guide and runnable examples cover execution. The declarations below link to constructor contracts. Parser limits affect analysis, not server execution.
What Seagull understands
- Backtick identifiers,
AUTO_INCREMENT, unsigned integers,ENUMandSETcolumns, generated columns. INSERT ... ON DUPLICATE KEY UPDATE,INSERT IGNORE,REPLACE INTO, index hints,LIMIT/OFFSET.- JSON columns, functions and the
->and->>operators,GROUP_CONCAT, window functions, date and string functions. YEARas a 16-bit integer (int16) andBIT(n)as binary ([]byte), the forms go-sql-driver returns them in.TIMEuses the time-of-day slot, which the database/sql emitter sets tostringfor go-sql-driver, since the driver returns it as text.- A type's modifiers are one or two unsigned numbers, as in
DECIMAL(10, 2); a third one, a sign, a word, a nested or empty list, or a list left open is a syntax error (Q010). A modifier written as a fraction, an exponent or a0xor0binteger is read as its value, soDECIMAL(10.5, 2)has precision 10 andVARCHAR(1e3)a length of 1000. A type keeps its modifiers wherever it is written: in a column, a routine's argument and return types, aJSON_TABLEor table-function column (price DECIMAL(10, 2) PATH '$.price'), a type a directive names (type: 'decimal(10,2)') and the target ofCAST,CONVERTandJSON_VALUE ... RETURNING, soCAST(x AS DECIMAL(10, 2))has precision 10 and scale 2 andCONVERT(x, CHAR(20))a length of 20, and a placeholder they convert takes that type. FLOAT(p)is aFLOAT(float32) forpup to 24 and aDOUBLE(float64) from 25 to 53, as MySQL makes it, in a column and in a conversion, and a largerpis a syntax error (Q010);FLOAT(M, D)stays aFLOAT.FLOAT4andFLOAT8areFLOATandDOUBLE.- An
ENUMholds at most 65,535 values and aSETat most 64 members, and more is a syntax error (Q010). A member written as a hexadecimal or bit literal, such as0x61,X'61'orb'1100001', is the value it spells, herea. - Stored functions created in migrations and their read-only detection, with or without
DELIMITER, which the analyser and the migration runner both honour; see Migrations and seeds. A routine's body is kept exactly as the migration writes it, so anIF ... END IFor aCASE ... ENDinside it never cuts it short, and a function that writes after such a block changes data. - DDL is read whole, and what MySQL refuses is a syntax error (Q010): words left after a complete
CREATE,DROP,ALTERorRENAME, aCREATE VIEWwithoutAS, a column list missing a comma or its),DROP INDEXor a trigger withoutONand its table, aFOREIGN KEYwithoutREFERENCESand a table, and an unknownCREATE DATABASEor index option.DROP TABLE a, bandDROP VIEW v1, v2drop every name in the list,IF EXISTSapplying to each,DROP TEMPORARY TABLEis applied, andALTER TABLE t ADD (a INT, b INT), with or withoutCOLUMNandIF NOT EXISTS, adds every column it lists. Creating a table under a name a table already holds, or renaming one onto a name in use, is Q067, and altering a table, or dropping a table, view or function, that does not exist withoutIF EXISTSis Q068. - A view or
CREATE TABLE ... ASquery with a syntax error fails the statement, and a view may end inWITH [CASCADED | LOCAL] CHECK OPTION. A view whose query Seagull does not analyse, aTABLEstatement or one nested pastmax_parse_depth, is Q069: it keeps only the column names it declares, without types. CALLstatements, whose arguments' parameters are typed from the procedure. Statements with nothing to analyse, such asSETorTRUNCATE, generate with no output.SHOW WARNINGSandSHOW ERRORS(Level,Codeas anINT UNSIGNED,Message),SHOW COUNT(*) WARNINGSandERRORS(a nullableBIGINT UNSIGNED),SHOW [GLOBAL | SESSION] VARIABLESandSTATUS(Variable_name, andValue, which MySQL may leaveNULL),SHOW DATABASES,SHOW [FULL] TABLES(Tables_in_<database>, named after the databaseFROMorINnames andTables_in_databaseotherwise, andTable_typewithFULL) andSHOW CREATE TABLE(Table,Create Table) return those columns, as MySQL 8.4 and MariaDB 11 give them. Any otherSHOWstatement, whose columns Seagull does not declare, is Q010, and so is a parameter in one; read such data frominformation_schemainstead.SHOW WARNINGSreads the warnings of the connection's last statement, so run both on one connection, such as in a transaction.IFNULL(a, b)isNULLonly when both arguments may be, soIFNULL(nullable, 0)is neverNULL.- Numeric functions are typed from their arguments as MySQL types them:
ABSkeeps its argument's type,CEILandFLOORkeep aDOUBLEor an integer and make aDECIMALaBIGINT,MODtakes the wider operand's type and may beNULL(MOD(x, 0)),ROUNDandTRUNCATEkeep aDECIMAL, andCRC32is anINT UNSIGNED(uint32), so no result is read into a narrower Go integer. ADECIMALpasses to a function taking aDOUBLE, and a number or a date and time to a string function, as MySQL converts them. - A parameter compared with an expression that names no column, as in
WHERE price * 2 > ?orSELECT id + 1 = ?, takes that expression's type. A placeholder that is a function argument, or a value ofIN,BETWEEN,CASTorCONVERT, takes the type and name of the place it is written in: inLPAD(?, ?, ?)they are namedstring,lengthandpad, and the length is an integer. CURRENT_TIMESTAMP,UTC_TIMESTAMP,NOW,CURTIME,SYSDATEand the other current-time functions accept a fractional seconds precision, as inUTC_TIMESTAMP(6).- A string or a number passed to a date or time function is read as a date or time, as MySQL reads it:
YEAR('2024-01-01'),DATEDIFF(NOW(), '2024-01-01')orMONTH(varchar_column)analyse without a diagnostic, and the call may beNULL, since a string that holds no date givesNULL.DATE_ADD,DATE_SUB,ADDDATE,SUBDATE,ADDTIME,SUBTIMEandTIMESTAMPADDgive the type of the value they shift: a string for a string or a number, aTIMEfor aTIME, and aDATETIMEotherwise.UNIX_TIMESTAMPof a string, a parameter or aDATETIMEwith fractional seconds is aDECIMAL, and may beNULL. A string still never converts to a date in aCASEorCOALESCE, which MySQL types as a string. COUNT(DISTINCT a, b)counts the distinct combinations with noNULLamong them;UUID_TO_BIN(uuid, 1)andBIN_TO_UUID(bytes, 1)take the swap flag that orders time-based keys, and areNULLonly for aNULLvalue;LAST_INSERT_ID(expression)sets and returns the value a laterLAST_INSERT_ID()reads, as inUPDATE counters SET value = LAST_INSERT_ID(value + 1); andIS_IPV4,IS_IPV6,IS_IPV4_MAPPEDandIS_IPV4_COMPATtest addresses.JSON_EXTRACT,JSON_KEYS,JSON_LENGTHandJSON_CONTAINSwith a path, and the->and->>operators, are nullable even over aNOT NULLdocument, since a missing path yieldsNULL.CAST(... AS SIGNED)andUNSIGNEDare 64-bit integers.- A
TABLE tstatement, which Seagull does not analyse, and square-bracket syntax, such as an array subscript, which MySQL does not have, are rejected (Q010), and so is a parenthesis left open or closing nothing, as inSELECT (((1or an unclosed call in a select list. INSERT INTO t (a) (SELECT ...)is analysed as theINSERT's query, so its parameters take the target columns' types; a parenthesis after the column list that opens no query, as inINSERT INTO t (a) (1), is a syntax error.- A system variable's qualifier is read in any case, as in
@@GLOBAL.time_zoneor@@Session.sql_mode. - An executable comment opener (
/*!or/*M!) inside a--or#comment, or inside an ordinary block comment, stays part of that comment. - Set operations over queries in parentheses, each with its own
ORDER BYandLIMIT, as in(SELECT ... LIMIT 1) UNION ALL (SELECT ... LIMIT 1) ORDER BY id, and overVALUES ROW(...)lists, as inVALUES ROW(0, 'none') UNION ALL SELECT id, name FROM items, in a statement, a derived table, a CTE (the anchor of a recursive one included), a subquery and a view. A set operation in parentheses that is an arm of another is read as more arms of it, which types the result alike. "..."is a string, as MySQL's defaultsql_modereads it, soCONCAT(a, ":", b)joins three strings; strings written next to each other, as in'a' "b", are one, and a string written after a select-list item names it, as inSELECT 1 'one'. A server whosesql_modeholdsANSI_QUOTESreads"..."as a quoted identifier; declare it withansi_quotes: trueunder the database'sdialect_options, ordialect_engine_mysql.WithANSIQuotes(true)in Go, and give a migration runtime you build yourselfmigrate_backend_mysql.WithANSIQuotes(true)so it splits files as the generator reads them (sgl migrateandsgl seedpass it fromsgl.yaml).- A division or a remainder (
/,%,DIV,MOD) is nullable unless its divisor is a number other than zero written in the statement, since MySQL givesNULLfor a division by zero:price / quantityis nullable andprice / 2is not.STR_TO_DATE, and a conversion of a string or a number to a date or time, such asCAST(born AS DATE)orCONVERT(born, DATETIME), are nullable too, since MySQL givesNULLfor a value that holds no date; a conversion of a date or time is not. - A select-list alias may be an operand in
GROUP BY,HAVINGandORDER BY, as inHAVING total > ?orORDER BY total * 2, where MySQL resolves it, and a parameter compared with one takes the type of the item it names.WHEREcannot name an alias, as in MySQL. - A
CHAR,VARCHARorTEXTcolumn declaredCHARACTER SET binaryis the binary type MySQL makes of it,BINARY,VARBINARYor aBLOB, read as[]byte. - An unquoted reserved word used as an alias, such as
SELECT 1 AS stored, is not reported, though MySQL rejects it; quote it with backticks. - A column read outside every aggregate of a query that aggregates without
GROUP BY, as inSELECT title, COUNT(*) FROM books, is Q062, a warning: MySQL rejects the query in its default mode, whosesql_modeholdsONLY_FULL_GROUP_BY, and otherwise returns one row in which the column isNULLwhen nothing matches, so the column is nullable. - A comparison,
BETWEEN,INlist, pattern match,NOTor logical operator over a value that may beNULLmay beNULLitself;IS NULLand<=>never are. A row comparison or a rowINlist, as in(published, id) > (?, ?), types each parameter from the element at its position, and a parameter inside an expression anINSERTorSETassigns, as inDATE_ADD(published, INTERVAL ? DAY), is not typed as the column; an argument ofGREATEST,LEASTorIFNULLtakes the type of the others, beside a number it takes the number's type, as inSET n = n + ?, whiletitle || ?leaves it asany, since||is a logical OR in the defaultsql_mode. ||is a logical OR, as MySQL's defaultsql_modereads it, and is typed as one. A||whose operand is text by its form, a string literal, a conversion to a string or a string function, is most likely meant to join strings, as it does on other engines, so it is Q063, a warning:name LIKE '%' || ? || '%'matches every row. Join strings withCONCAT. A||between two columns or parameters is not reported, since the parser does not know their types. A server whosesql_modeholdsPIPES_AS_CONCATis not modelled.
Parse limits
max_parse_depth (256 by default, at most 4,096) and max_tokens_per_statement (100,000
by default, at most 1,000,000) under
dialect_options, or
dialect_engine_mysql.WithMaxParseDepth and WithMaxTokensPerStatement in Go, bound what one statement
may cost; JSON_TABLE's NESTED PATH clauses count towards the depth. Tokenising stops
at the token budget, and a statement past it fails when it is analysed, or applied as DDL
Seagull reads, while a large seed INSERT in a migration is still passed over. Such a
statement is still read to its end for a missing semicolon, so a statement it runs on into
is reported (Q010) at that statement's first word. Either
limit fails the statement with an error matching seagull.ErrLimit. The Go options hold a
value above the ceiling at the ceiling; sgl.yaml and dialect_registry.New refuse it. The same
limits apply to MariaDB.
Parameters
? positional parameters and named :name parameters. A named parameter is sent as ?
and bound once for each place it appears. :name is a parameter where a value is
expected; a colon written directly after a name ends a statement label, as in l1:LOOP.
A placeholder that is the whole search of MATCH (...) AGAINST (?) is text, and the
amount of INTERVAL ? DAY or another single unit a number. A placeholder in a statement
Seagull runs without analysis, such as SET @user_id = ?, cannot be bound and is
Q010.
Migrations
Advisory locks through GET_LOCK (migrate_backend_mysql.New()). Files are split into statements as the dialect splits them, DELIMITER lines included. Migrations run one statement at a time and outside a transaction, since MySQL commits DDL implicitly, and record their progress so that one that fails part-way resumes at the failed statement. Seeds commit with their history row in a transaction; they are split too, unless migrate.WithDriverMultiStatements(true) says the DSN sets multiStatements=true, when each seed file runs in one call. See MySQL and MariaDB.
A run applies its migrations on one session and qualifies the history and seed tables with
the database DATABASE() names when it starts, so a USE in a migration moves the
statements after it but not the history. The generator does not follow USE: name the
database in the statement instead, as in CREATE TABLE other.widgets. See
Sessions and the search path.
Drivers
database/sql with github.com/go-sql-driver/mysql.
The DSN must set parseTime=true, as in
app:secret@tcp(localhost:3306)/shop?parseTime=true. The generated code reads
DATETIME, DATE and TIMESTAMP columns into time.Time, which go-sql-driver returns
only with that setting; without it, it returns their text as bytes and the first read
fails with unsupported Scan, storing driver.Value type []uint8 into type *time.Time.
The generated package's doc comment says so. TIME reads as a string either way.
A binary column, such as BINARY(16), whose Go type an override chose binds the bytes the
value marshals itself to (encoding.BinaryMarshaler), so a github.com/google/uuid.UUID
override on a BINARY(16) key stores its 16 bytes and a lookup by the UUID finds the row,
where its driver.Valuer's 36 characters would not fit. See
What an override type must support.
Exported declarations
dialect_engine_mysql
const Family = mysql.Family
func New(options ...Option) dialect.Dialect
type Option = mysql.Option
func WithANSIQuotes(enabled bool) Option
func WithMaxParseDepth(depth int) Option
func WithMaxTokensPerStatement(limit int) Option
func WithName(name string) Option
func WithReturning(statements dialect.DataChange) Option