Skip to content
gohacks.dev
Databases

Connect Go to MySQL and MariaDB Without Any Surprises

Connect Go to MySQL or MariaDB with go-sql-driver/mysql: build the DSN with a Config, set parseTime and charset, size the pool and avoid stale connections.

7 min read
Summarize in AI
Flat illustration of a database beside the table it holds, with the MySQL and MariaDB logos.

MySQL and MariaDB share a protocol and the same Go driver, github.com/go-sql-driver/mysql. Connecting is easy; getting the defaults right is where the surprises live. A DATETIME that comes back as bytes, accented text that turns into question marks, a connection that dies after eight hours of idleness. This article builds a connection that avoids those, on Go 1.25.6 against MySQL 8.4.

Tested with Go 1.25.6, go-sql-driver/mysql v1.9.3, MySQL 8.4.11 in Docker, macOS on Apple Silicon (arm64)

Start a database

Shell
docker run -d --name my -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=lab -p 33099:3306 mysql:8.4

MariaDB works the same way with the mariadb image; the driver detects it from the handshake.

Build the DSN from a Config

main.goGo
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"time"

	"github.com/go-sql-driver/mysql"
)

func main() {
	ctx := context.Background()
	cfg := mysql.NewConfig()
	cfg.User, cfg.Passwd = "root", "secret"
	cfg.Net, cfg.Addr, cfg.DBName = "tcp", "127.0.0.1:33099", "lab"
	cfg.ParseTime = true
	cfg.Loc = time.UTC
	cfg.Params = map[string]string{"charset": "utf8mb4"}
	cfg.Timeout = 3 * time.Second
	fmt.Println("DSN:", cfg.FormatDSN())

	db, err := sql.Open("mysql", cfg.FormatDSN())
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	db.SetMaxOpenConns(4)
	db.SetConnMaxLifetime(3 * time.Minute) // shorter than the server's wait_timeout
	if err := db.PingContext(ctx); err != nil {
		log.Fatal(err)
	}
	var ver, coll string
	_ = db.QueryRowContext(ctx, `select version(), @@collation_connection`).Scan(&ver, &coll)
	fmt.Printf("MySQL %s, connection collation %s\n", ver, coll)

	_, _ = db.ExecContext(ctx, `drop table if exists t577`)
	_, err = db.ExecContext(ctx, `create table t577 (id int primary key, at datetime, name varchar(20)) default charset utf8mb4`)
	if err != nil {
		log.Fatal(err)
	}
	when := time.Date(2026, 3, 1, 12, 30, 0, 0, time.UTC)
	_, _ = db.ExecContext(ctx, `insert into t577 values (1, ?, 'héllo 世界')`, when)

	var got time.Time
	var name string
	_ = db.QueryRowContext(ctx, `select at, name from t577 where id = 1`).Scan(&got, &name)
	fmt.Printf("parseTime=true gives time.Time: %v (%s)\n", got.Format(time.RFC3339), name)

	// Without parseTime the same column scans into []byte and fails into time.Time.
	plain, _ := sql.Open("mysql", "root:secret@tcp(127.0.0.1:33099)/lab")
	defer plain.Close()
	var t2 time.Time
	err = plain.QueryRowContext(ctx, `select at from t577 where id = 1`).Scan(&t2)
	fmt.Printf("parseTime missing: %.80s\n", fmt.Sprint(err))

	// The error you get when the credentials are wrong.
	bad, _ := sql.Open("mysql", "root:nope@tcp(127.0.0.1:33099)/lab")
	defer bad.Close()
	fmt.Printf("bad password: %.36s\n", fmt.Sprint(bad.PingContext(ctx)))
	_, _ = db.ExecContext(ctx, `drop table t577`)
}
Text
DSN: root:secret@tcp(127.0.0.1:33099)/lab?parseTime=true&timeout=3s&charset=utf8mb4
MySQL 8.4.11, connection collation utf8mb4_0900_ai_ci
parseTime=true gives time.Time: 2026-03-01T12:30:00Z (héllo 世界)
parseTime missing: sql: Scan error on column index 0, name "at": unsupported Scan, storing driver.V
bad password: Error 1045 (28000): Access denied fo

A DSN written by hand, user:pass@tcp(host:3306)/db?parseTime=true, breaks as soon as the password contains @ or /. The driver's Config struct avoids that: set fields, call FormatDSN. The program prints the result:

Text
root:secret@tcp(127.0.0.1:33099)/lab?parseTime=true&timeout=3s&charset=utf8mb4

The settings that matter, with the reason for each:

SettingWhy
ParseTime = trueScan DATE, DATETIME and TIMESTAMP into time.Time. Without it, you get []byte
Loc = time.UTCThe location used when converting to and from time.Time. The default is UTC; set it deliberately
charset=utf8mb4Full Unicode, including emoji. The server default is utf8mb4 in MySQL 8, and legacy utf8 in older servers is a 3-byte subset
Timeout = 3sThe dial timeout. Without it a dead host blocks for the OS timeout
ReadTimeout, WriteTimeoutBound socket reads and writes; leave unset if you use contexts for every call
CheckConnLivenessOn by default; the driver checks idle connections before reuse
CollationPick explicitly when comparisons must be case or accent sensitive

The driver reads the DSN, but the safer route in a service is to build the Config from environment variables and call FormatDSN once.

The pool, and the eight-hour trap

After sql.Open, the program sets SetMaxOpenConns(4) and SetConnMaxLifetime(3 * time.Minute). The lifetime matters more with MySQL than with most databases. The server closes connections that are idle longer than wait_timeout, 28800 seconds (eight hours) by default and often much lower on managed services and behind proxies. The next use of such a connection fails with invalid connection or driver: bad connection. The driver retries safe cases, but a lifetime below the server's timeout stops the problem at the source. Check the actual value with select @@wait_timeout, and set ConnMaxLifetime a minute or two below it, or below any proxy's idle limit.

The program pings at start-up and reads select version(), @@collation_connection: MySQL 8.4.11 and utf8mb4_0900_ai_ci. Logging those two at boot answers the first questions in many incidents.

Time values

The table in the lab has a DATETIME column. A time.Time goes in as 2026-03-01 12:30:00 in UTC; with parseTime=true it comes back as 2026-03-01T12:30:00Z. The second pool in the program uses a DSN without parseTime and scans the same column into a time.Time; the result is an error:

Text
sql: Scan error on column index 0, name "at": unsupported Scan, storing driver.Value type []uint8 into type *time.Time

The driver hands back raw bytes for a DATETIME unless told to parse. Add the flag and the scan works. Note that a DATETIME has no time zone: Loc decides how the driver interprets the stored wall clock, and a TIMESTAMP column is converted by the server using its session time zone. The cleanest policy is to store UTC everywhere, set Loc = time.UTC and keep time_zone on the server at +00:00; see timestamps and time zones for the PostgreSQL side of the same problem.

Text and encodings

The table is created with default charset utf8mb4 and the row holds héllo 世界. It comes back unchanged. If a connection used a different charset than the column, the text would be corrupted or replaced with question marks. Two things prevent that: charset=utf8mb4 on the connection and a utf8mb4 column or table default. MySQL's old utf8 is an alias for utf8mb3, which cannot store four-byte characters, so an emoji insert fails or is truncated. For new schemas always say utf8mb4.

Errors worth recognising

  • Error 1045 (28000): Access denied for user ... appears at Ping for bad credentials. The lab prints it for a wrong password. The message includes the client's host as the server sees it.
  • Error 1040: Too many connections means the server's max_connections is exhausted; see too many connections.
  • Error 1213: Deadlock found when trying to get lock is retryable: re-run the whole transaction; see retrying serialization failures for the pattern.
  • driver: bad connection or invalid connection points at a stale connection; check ConnMaxLifetime.

Detect specific server errors with errors.As(err, &mysqlErr) against *mysql.MySQLError and compare Number, instead of matching text.

MariaDB and other MySQL-compatible servers

The driver speaks to MariaDB, Percona, TiDB and Aurora MySQL. Differences are in features, not connection: MariaDB lacks some JSON behaviour, collation names differ (utf8mb4_general_ci is the typical default), and RETURNING exists on MariaDB but not on MySQL. Test against the server you deploy, not only the one on your laptop.

Transactions, isolation and the storage engine

MySQL's default isolation level is REPEATABLE READ, which differs from PostgreSQL's READ COMMITTED. With InnoDB, a transaction that reads a row twice sees the same value both times, and range reads can take gap locks that block inserts. If you port code between the two, the same sequence of statements can behave differently, and the difference only shows under concurrency. Set the level explicitly with sql.TxOptions{Isolation: ...} when it matters, and test with two connections as the isolation article does for PostgreSQL. Tables created with an engine other than InnoDB, such as the legacy MyISAM, ignore transactions entirely: a Rollback on them does nothing. Check show create table on any inherited schema.

Placeholders and multiple statements

The driver uses ? placeholders, not $1. By default it sends parameters through the server's prepared statement protocol, which gives you type fidelity. It refuses to run several statements in one Exec unless the DSN has multiStatements=true, and you should leave that off: it is the switch that turns a SQL injection into stacked statements. The default is a safety feature.

Before you ship

  1. Build the DSN from a Config, never by string concatenation.
  2. parseTime=true, utf8mb4, an explicit Loc.
  3. A dial timeout and a context on every query.
  4. ConnMaxLifetime below the server and proxy idle timeouts, MaxOpenConns within the server's max_connections budget.
  5. Ping at start-up and log the server version.
  6. TLS with tls=true or a registered config in production.

The driver's README documents every DSN parameter, and the MySQL manual explains wait_timeout. The shared pool model is in the database/sql explainer.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.