diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..62bdf8f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,59 @@
+name: Bug report
+description: Something in GSDB doesn't work as expected
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report a bug. Please **do not** report security issues here; see SECURITY.md.
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: What did you expect, and what happened instead? Include any error messages.
+ validations:
+ required: true
+ - type: textarea
+ id: repro
+ attributes:
+ label: Minimal reproduction
+ description: The struct definition (with `db` tags), the table schema, and the GSDB calls that trigger the problem.
+ render: go
+ validations:
+ required: true
+ - type: textarea
+ id: sql
+ attributes:
+ label: Generated SQL / logs
+ description: Output with `gsdb.ShowSQL = true`, if relevant. Remove any secrets.
+ render: text
+ - type: input
+ id: version
+ attributes:
+ label: GSDB version
+ placeholder: v0.0.14
+ validations:
+ required: true
+ - type: dropdown
+ id: database
+ attributes:
+ label: Database
+ options:
+ - MySQL
+ - MariaDB
+ - SQLite
+ - Other
+ validations:
+ required: true
+ - type: input
+ id: db-version
+ attributes:
+ label: Database version
+ placeholder: MySQL 8.4 / SQLite 3.46
+ - type: input
+ id: go-version
+ attributes:
+ label: Go version and OS
+ placeholder: go1.26.0 linux/amd64
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..88f942f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Report a security vulnerability
+ url: https://github.com/SpaceTent/GoStructDB/security/advisories/new
+ about: Please report security issues privately, not as public issues. See SECURITY.md.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..56c1f16
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,27 @@
+name: Feature request
+description: Suggest an idea or improvement for GSDB
+labels: ["enhancement"]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: What problem are you trying to solve?
+ description: Describe the use case. GSDB deliberately keeps SQL visible, so explain where the current API gets in the way.
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ description: What would you like GSDB to do? An example of the API you'd like to write helps a lot.
+ render: go
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ - type: checkboxes
+ id: contribute
+ attributes:
+ label: Contribution
+ options:
+ - label: I'd be willing to open a pull request for this.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..f76019b
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,25 @@
+## What does this PR do?
+
+
+
+Fixes #
+
+## Type of change
+
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Documentation
+- [ ] Refactor / chore
+
+## Checklist
+
+- [ ] `go build ./...` passes
+- [ ] `go vet ./...` passes
+- [ ] `go test ./...` passes
+- [ ] Code is formatted (`gofmt -s -w .` / `goimports -w .`)
+- [ ] Docs (README / docs/) updated if behaviour changed
+- [ ] CHANGELOG.md updated under **Unreleased** for user-facing changes
+
+## How was this tested?
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..944e63c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,60 @@
+# Changelog
+
+All notable changes to this project are documented here.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
+project uses [Semantic Versioning](https://semver.org/) tags (pre-1.0, so minor API changes
+can happen in any `v0.0.x` release). Earlier entries were reconstructed from the git history
+and GitHub releases.
+
+## [Unreleased]
+
+### Added
+- LICENSE (MIT), CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, issue and PR templates.
+- Expanded README, `docs/getting-started.md`, `docs/architecture.md` and `examples/README.md`.
+
+## [v0.0.14] - 2026-09-08
+
+### Changed
+- Connection pool size is now configurable through `gsdb.MaxOpenConns` and
+ `gsdb.MaxIdleConns` (default 50 each, previously fixed at 25).
+
+## [v0.0.13] - 2026-03-06
+
+### Added
+- `Save` example for `readdefault=null` dates (`examples/Save-DateDefault.go`).
+
+### Changed
+- Examples now run against SQLite (`NewSQLite3`) by default.
+
+## [v0.0.12] - 2026-03-04
+
+### Added
+- `readdefault=null` for `time.Time` fields: zero times are written as SQL `NULL` on
+ insert/update, and `NULL` is read back as the zero time.
+
+## [v0.0.11] - 2026-02-25
+
+### Added
+- Tests for `Execute`, `Record` insert/update and `QueryStruct`.
+
+### Changed
+- README re-worded and typos fixed.
+- Dependency updates.
+
+## [v0.0.7] - 2025-05-14
+
+### Added
+- First GitHub release: `New`, `NewSQLite3`, `Execute`, `Query`, `QueryStruct`,
+ `QuerySingleStruct`, `Insert`, `Update`, `Save`, `Record`/`Field` helpers, counters,
+ and a GitHub Actions release workflow.
+
+Tags `v0.0.0`–`v0.0.6` and `v0.0.8`–`v0.0.10` exist in git but have no GitHub release
+or separate notes; their changes are folded into the nearest release above.
+
+[Unreleased]: https://github.com/SpaceTent/GoStructDB/compare/v0.0.14...HEAD
+[v0.0.14]: https://github.com/SpaceTent/GoStructDB/compare/v0.0.13...v0.0.14
+[v0.0.13]: https://github.com/SpaceTent/GoStructDB/compare/v0.0.12...v0.0.13
+[v0.0.12]: https://github.com/SpaceTent/GoStructDB/compare/v0.0.11...v0.0.12
+[v0.0.11]: https://github.com/SpaceTent/GoStructDB/compare/v0.0.7...v0.0.11
+[v0.0.7]: https://github.com/SpaceTent/GoStructDB/releases/tag/v0.0.7
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..1b85851
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,83 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **mark@ackroyd.net**. All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..4bb751d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,95 @@
+# Contributing to GoStructDB
+
+Thanks for your interest in improving GSDB! Bug reports, docs fixes, tests and features are
+all welcome.
+
+By taking part you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
+
+## Before you start
+
+- **Bugs:** search [existing issues](https://github.com/SpaceTent/GoStructDB/issues) first,
+ then open a bug report with a minimal reproduction (struct, table schema, GSDB calls).
+- **Features:** open a feature request to discuss the idea before writing a lot of code.
+ GSDB is deliberately opinionated (SQL stays visible, structs stay primitive), so it's
+ worth checking the idea fits.
+- **Security issues:** don't open a public issue; follow [SECURITY.md](SECURITY.md).
+
+## Development setup
+
+You need:
+
+- Go **1.26+** (see `go.mod`)
+- A C compiler (`gcc`/`clang`) with cgo enabled, for `mattn/go-sqlite3`
+- Optional: `goimports` and `govulncheck` (used by `make fmt` / `make commit`)
+
+```bash
+git clone https://github.com/SpaceTent/GoStructDB.git
+cd GoStructDB
+go build ./...
+go test ./...
+```
+
+You don't need a database server to run the tests: unit tests use
+[`go-sqlmock`](https://github.com/DATA-DOG/go-sqlmock) and the integration tests use a
+temporary SQLite file.
+
+Optional tools:
+
+```bash
+go install golang.org/x/tools/cmd/goimports@latest
+go install golang.org/x/vuln/cmd/govulncheck@latest
+```
+
+## Making changes
+
+1. Fork the repo and create a branch from `main`:
+ `git checkout -b fix/short-description` (or `feat/...`, `docs/...`, `test/...`).
+2. Make your change. Keep it focused: one fix or feature per pull request.
+3. Add or update tests in `gsdb/*_test.go`. Table-driven tests with `testify/assert`
+ match the existing style.
+4. Format, vet and test:
+
+ ```bash
+ gofmt -s -w .
+ goimports -w . # if installed
+ go vet ./...
+ go test ./... # or: make test (writes profile.cov with coverage)
+ ```
+
+5. Update `README.md` / `docs/` if behaviour or the API changed, and add a line under
+ **Unreleased** in [CHANGELOG.md](CHANGELOG.md).
+
+### Make targets
+
+| Target | What it does |
+| --- | --- |
+| `make test` | `go test -v -coverprofile=profile.cov ./...` |
+| `make fmt` | `go mod tidy`, `goimports`, `gofmt -s` |
+| `make run` | Formats, then runs `main.go` (all examples; see [examples/README.md](examples/README.md)) |
+| `make commit` / `make release` | Maintainer shortcuts that commit, tag and **push**. Contributors should use normal git and pull requests instead. |
+
+## Commit messages
+
+Short, imperative subject lines. A [Conventional Commits](https://www.conventionalcommits.org/)
+style prefix is preferred and already used in the history:
+
+```text
+fix: handle NULL dates in QuerySingleStruct
+feat: support uint primary keys in Save
+docs: document readdefault=null
+test: add Update tests for bool fields
+chore: bump go-sql-driver/mysql
+```
+
+## Pull requests
+
+- Open the PR against `main` and fill in the template.
+- CI (`.github/workflows/go.yml`) runs `go test -v ./...` on every pull request; it must pass.
+- Keep PRs small enough to review. Explain *why* as well as *what*.
+- Maintainers may squash commits when merging.
+
+## Releases (maintainers)
+
+Releases are tagged `v0.0.N`. Pushing a `v*` tag runs `.github/workflows/release.yml`, which
+creates a GitHub release with generated notes. Move the **Unreleased** entries in
+`CHANGELOG.md` under the new version when tagging.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a6f48dc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 SpaceTent
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 5e22976..f1b92f4 100644
--- a/README.md
+++ b/README.md
@@ -1,140 +1,399 @@
-# Go StructDB - gsdb
+# GoStructDB (gsdb)
+
+[](https://pkg.go.dev/github.com/SpaceTent/GoStructDB/gsdb)
+[](https://github.com/SpaceTent/GoStructDB/actions/workflows/go.yml)
+[](https://goreportcard.com/report/github.com/SpaceTent/GoStructDB)
+[](https://github.com/SpaceTent/GoStructDB/releases)
+[](LICENSE)
+
+**An opinionated database library for Go. It's got some ORM, but it doesn't hide the SQL.**
+
+GSDB maps plain Go structs to database rows using `db:"..."` struct tags. It writes the
+boilerplate `INSERT` / `UPDATE` statements for you, loads query results straight into
+structs, and gives you a `.Save()` that inserts or updates depending on the primary key.
+You still write your own `SELECT`s.
+
+Works with **MySQL / MariaDB** and **SQLite**.
+
+---
+
+## Contents
+
+- [Why GSDB?](#why-gsdb)
+- [Features](#features)
+- [Requirements](#requirements)
+- [Installation](#installation)
+- [Quick start](#quick-start)
+- [Connecting](#connecting)
+- [Struct tags](#struct-tags)
+- [Usage](#usage)
+- [Configuration](#configuration)
+- [Trade-offs and caveats](#trade-offs-and-caveats)
+- [Project layout](#project-layout)
+- [Running the examples](#running-the-examples)
+- [Status and roadmap](#status-and-roadmap)
+- [Contributing](#contributing)
+- [License](#license)
+
+## Why GSDB?
+
+While GORM is a popular choice, I believe strongly in developers understanding and writing
+SQL queries directly. SQL proficiency should be a fundamental skill for all developers.
+However, Go's verbose syntax can make database interactions lengthy and repetitive.
+
+GSDB gives you some ORM features, but you still write SQL. It provides direct access to run
+queries and read the results through simple `Record` and `Field` types, and it translates
+database rows into structs (and structs into `INSERT`/`UPDATE` SQL) when the struct carries
+`db` tags describing columns, primary keys and read defaults.
+
+Another thing I didn't like about SQL handling in Go was making structs contain SQL types
+such as `sql.NullString`. It ties your struct to a database and increases the scaffolding
+you need to work with the data. With GSDB your fields stay primitive: `string`, `int`,
+`bool`, `float64`, `time.Time`.
+
+GSDB is designed to remove boilerplate and make CRUD applications easy to write, but that
+ease of use comes with trade-offs (see [Trade-offs and caveats](#trade-offs-and-caveats)).
+If you need fast, high-performance, memory-efficient database access, GSDB probably isn't
+for you.
+
+## Features
+
+- **Struct ⇄ row mapping** with `db:"column=... primarykey=yes table=..."` tags.
+- **Generated SQL**: `DB.Insert(v)` and `DB.Update(v)` build the SQL for a struct; you decide when to run it.
+- **`DB.Save(v, pk)`**: `INSERT` when the primary key is zero, `UPDATE` otherwise.
+- **Typed reads** with generics: `QueryStruct[T]` returns `[]T`, `QuerySingleStruct[T]` returns `T`.
+- **Untyped reads**: `DB.Query` returns `[]Record`, and each `Field` has converters (`AsString`, `AsInt`, `AsDate`, ...).
+- **No `sql.Null*` in your structs**: `NULL` strings and numbers read as zero values, and `NULL` dates are controlled per field with `readdefault`.
+- **Hex-encoded string literals** in generated SQL (`X'...'`), so quotes and odd characters are written safely.
+- **Query counters** to count how many statements ran during a block of code (experimental).
+- **Optional SQL logging** via `log/slog`.
+
+## Requirements
+
+- **Go 1.26+** (the version declared in `go.mod`).
+- **cgo and a C compiler** (`gcc` or `clang`) for SQLite. GSDB uses
+ [`mattn/go-sqlite3`](https://github.com/mattn/go-sqlite3); with `CGO_ENABLED=0` the build
+ still succeeds, but SQLite fails at runtime with *"go-sqlite3 requires cgo to work"*.
+ MySQL works either way.
+- **A database**: MySQL/MariaDB (via [`go-sql-driver/mysql`](https://github.com/go-sql-driver/mysql))
+ or a SQLite file.
+
+## Installation
+
+```bash
+go get github.com/SpaceTent/GoStructDB@latest
+```
-Opinionated Database Extraction Library for Go, It's got some ORM, but does not hide the SQL.
+Then import the `gsdb` package:
-While GORM is a popular choice, I believe strongly in developers understanding and writing SQL queries directly.
+```go
+import "github.com/SpaceTent/GoStructDB/gsdb"
+```
-SQL proficiency should be a fundamental skill for all developers. However, Go's verbose syntax can make database
-interactions quite lengthy and repetitive.
+## Quick start
-GSDB give you some ORM features but you still need to write SQL.
+This complete program uses SQLite, so it runs with no database server. Save it as
+`main.go` in a new module (`go mod init example && go get github.com/SpaceTent/GoStructDB@latest`)
+and run `go run .`.
-GSDB works for me, as it provides direct access to run queries and access the results with simple "record" and "field" types.
-It also provides Database to Struct transformations. If the struct has the extra "db" tags to define columns, primary keys read default conditions, GSDB will translate DB resources into Structs and provide methods such as .Save(); Making it easy to define a struct, load it with data and just call a DB.Save to save it to the database
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "time"
+
+ "github.com/SpaceTent/GoStructDB/gsdb"
+)
+
+// Person maps to the "people" table. The table name goes on the primary key field.
+type Person struct {
+ ID int `db:"column=id primarykey=yes table=people"`
+ Name string `db:"column=name"`
+ Email string `db:"column=email"`
+ Active bool `db:"column=active"`
+ Created time.Time `db:"column=created readdefault=zero"`
+ Notes string `db:"column=notes omit=yes"` // never written to the DB
+}
+
+func main() {
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+
+ // SQLite for the demo. For MySQL use gsdb.New("user:pass@tcp(localhost:3306)/mydb", logger, ctx).
+ gsdb.NewSQLite3("quickstart.db", logger, context.Background())
+
+ _, _, err := gsdb.DB.Execute(`CREATE TABLE IF NOT EXISTS people (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT, email TEXT, active BOOLEAN, created DATETIME)`)
+ if err != nil {
+ logger.Error(err.Error())
+ return
+ }
-GSDB is designed to abstract a lot of the boilerplate DB code away from you. It makes writing CRUD applications easier, but, with that ease of use, comes trade-offs.
-GSDB uses reflection to determine many of the type conversions. It also holds complete record sets in memory.
-If you need fast, high performance and efficient DB access, GSDB probably isn't for you.
+ // Insert: Save does an INSERT because the primary key is zero.
+ p := Person{Name: "Ada Lovelace", Email: "ada@example.com", Active: true, Created: time.Now()}
+ id, _, err := gsdb.DB.Save(p, p.ID)
+ if err != nil {
+ logger.Error(err.Error())
+ return
+ }
+ fmt.Println("inserted id:", id)
-Another thing I didn't like about SQL handling in Go, was making structs contain SQL data types such as sql.Null.
-To me this seems lazy. It ties your struct to a database, increasing the boilerplate and scaffolding you need to work with the data. To me, the data is either strings or dates. Then the struct is either string or time.Time.
+ // Read one row back into the struct.
+ ada, err := gsdb.QuerySingleStruct[Person]("SELECT * FROM people WHERE id = ?", id)
+ if err != nil {
+ logger.Error(err.Error())
+ return
+ }
+ fmt.Printf("loaded: %+v\n", ada)
-I'm keen to keep primitives, as primitive as possible.
+ // Update: Save does an UPDATE because the primary key is non-zero.
+ ada.Email = "ada@analytical.engine"
+ if _, rows, err := gsdb.DB.Save(ada, ada.ID); err == nil {
+ fmt.Println("updated rows:", rows)
+ }
-### Connecting
+ // See the SQL GSDB generates without running it.
+ sql, _ := gsdb.DB.Update(ada)
+ fmt.Println("generated SQL:", sql)
-To start a new DB connection, include GSDB library and declare a new connection
+ // Read many rows into a slice.
+ people, _ := gsdb.QueryStruct[Person]("SELECT * FROM people WHERE active = ?", true)
+ fmt.Println("active people:", len(people))
-```go
-gsdb.New(DataSource, StructuredLoggingHandler, context)
+ // Or skip structs and work with Records and Fields.
+ records, _ := gsdb.DB.Query("SELECT id, name FROM people")
+ for _, r := range records {
+ fmt.Println("record:", r["id"].AsInt(), r["name"].AsString())
+ }
+}
```
-### Executing Queries
+Real output from a first run (against `v0.0.14`):
+
+```text
+inserted id: 1
+loaded: {ID:1 Name:Ada Lovelace Email:ada@example.com Active:true Created:2026-10-10 17:05:46 +0000 UTC Notes:}
+updated rows: 1
+generated SQL: UPDATE people SET name=X'416461204c6f76656c616365',email=X'61646140616e616c79746963616c2e656e67696e65',active=true,created='2026-10-10 17:05:46' WHERE id=1;
+active people: 1
+record: 1 Ada Lovelace
+```
+
+Note the `Created` value comes back labelled UTC: see [time zones](#trade-offs-and-caveats).
+A longer walkthrough, including MySQL, is in [docs/getting-started.md](docs/getting-started.md).
+
+## Connecting
+
+GSDB keeps **one global connection** in `gsdb.DB`. Call one of the constructors once at
+start-up:
```go
-lastInsertedID, rowsAffected, err := gsdb.DB.Execute(sqlQuery,parameters...)
+// MySQL / MariaDB. The DSN uses go-sql-driver/mysql syntax.
+// The connection is opened lazily on the first query, with up to 3 ping attempts.
+gsdb.New("user:password@tcp(127.0.0.1:3306)/mydb", logger, ctx)
+
+// SQLite. The handle is set up immediately (no ping); the file is created if missing.
+gsdb.NewSQLite3("app.db", logger, ctx)
```
-### Making Insert and Update SQL
-gsdb provides 2 functions to make SQL statements from structs, DB.Insert and DB.Update. If you pass in any variable from a struct, the library will build the SQL necessary for an INSERT or UPDATE statement.
+Both take a `*slog.Logger` (used for errors and, if enabled, SQL logging) and a
+`context.Context`.
+
+## Struct tags
+
+Every exported field that GSDB writes needs a `db` tag. Options are space-separated
+`key=value` pairs:
+
+| Option | Example | Meaning |
+| --- | --- | --- |
+| `column` | `column=email` | **Required.** The database column name. `Insert`/`Update` return an error if an exported field has no `column`. |
+| `table` | `table=people` | The table name used by `Insert`/`Update`/`Save`. Put it on any one field; by convention the primary key. |
+| `primarykey` | `primarykey=yes` | Marks the primary key. It is left out of `INSERT` and used as the `WHERE` clause for `UPDATE`. |
+| `omit` | `omit=yes` | Never written by `Insert`/`Update`. Still filled by `QueryStruct` if the query returns that column. |
+| `readdefault` | `readdefault=zero` | `time.Time` only. What to return when the column is `NULL`: `now` (current time), `zero` (`0001-01-01 00:00:00`), or `null` (zero on read, **and** a zero time is written back as SQL `NULL`). |
+
+Unexported fields are ignored.
+
+**`readdefault` and `NULL` dates.** If no `readdefault` is set, a `NULL` date is read as
+`time.Now()`. Early versions of GSDB did this and some applications rely on it, so it
+remains the default. For new code use `readdefault=zero` (or `readdefault=null` if you also
+want zero times saved as `NULL`).
+
+**Supported field types**
+
+| Go type | Insert / Update | QueryStruct |
+| --- | --- | --- |
+| `int`, `int8`–`int64`, `uint`, `uint8`–`uint64` | ✅ | ✅ |
+| `float32`, `float64` | ✅ | ✅ |
+| `bool` | ✅ (written as `true`/`false`) | ✅ |
+| `string` | ✅ (written as hex `X'...'`) | ✅ |
+| `time.Time` | ✅ (written as `'YYYY-MM-DD HH:MM:SS'`) | ✅ |
+| `[]byte` | ❌ | ✅ |
+
+Pointers and `sql.Null*` types are not supported.
+
+## Usage
+
+### Executing statements
```go
- type InsertPerson struct {
- Id int `db:"column=id primarykey=yes table=Test"`
- Name string `db:"column=name"`
- Dtadded time.Time `db:"column=dtadded"`
- Status int `db:"column=status"`
- Ignored int `db:"column=ignored omit=yes"`
- }
-
- entry := InsertPerson{
- Name: "Test",
- Dtadded: time.Now(),
- Status: 1,
- }
- sqlQuery, err := MySQL.DB.Insert(entry)
- if err != nil {
- l.Error(err.Error())
- return
- }
-
- lastInsertedID, rowsAffected, err := MySQL.DB.Execute(sqlQuery)
- if err != nil {
- l.Error(err.Error())
- }
- l.Info(fmt.Sprintf("Item with ID %d was inserted. %d rows were affected", lastInsertedID, rowsAffected))
+lastInsertedID, rowsAffected, err := gsdb.DB.Execute("DELETE FROM people WHERE id = ?", 42)
```
-### Hex conversion of strings
+`Execute` passes parameters to `database/sql`, so use `?` placeholders for values.
+
+### Generating INSERT and UPDATE SQL
+
+`Insert` and `Update` only **build** SQL from a struct; run it with `Execute`.
-All strings are converted to HEX. This ensures even the most challenging characters are written to the DB as it also makes SQL injections attacks difficult.
+```go
+sqlQuery, err := gsdb.DB.Insert(person) // INSERT INTO people(name,email,...) VALUES (...);
+// or: sqlQuery, err := gsdb.DB.Update(person) // UPDATE people SET name=...,email=... WHERE id=1;
+if err != nil {
+ return err
+}
+lastInsertedID, rowsAffected, err := gsdb.DB.Execute(sqlQuery)
+```
+
+Strings are written as hex literals, which keeps quotes and unusual characters intact:
```sql
INSERT INTO Test(name,dtadded,status) VALUES (X'54657374','2025-12-25 15:29:25',1);
```
-### QueryStruct
+### Save
-QueryStruct will return a slice of type T containing all the data.
+`Save` takes the struct and its primary key value. If the key is the zero value it runs an
+`INSERT`; otherwise an `UPDATE`.
-### QuerySingleStruct
+```go
+id, rows, err := gsdb.DB.Save(person, person.ID)
+```
-This works the same as QueryStruct, but returns T, not a slice of type T.
-When returning datetimes from the database, there are some extra options to determine how you can view NULL or EMPTY dates in the database. "readdefault=now" or "readdefault=zero"
+### Reading into structs
-In early versions of GSDB, if a date field in the database was NULL, the library returned time.Now() instead. That behavior wasn’t really correct, but some applications had already built business logic around the idea that a NULL date meant “current time.” To preserve compatibility, GSDB kept this as the default.
+`QueryStruct` and `QuerySingleStruct` are package functions (Go does not allow generic
+methods). Columns are matched to fields by the `column` tag.
-However, the recommended approach is to use the tag readdefault=zero, which tells GSDB to return the Go “zero time” (0001-01-01 00:00:00) for NULL dates. This is usually the proper way to represent missing or unset timestamps.
+```go
+people, err := gsdb.QueryStruct[Person]("SELECT * FROM people WHERE active = ?", true)
-### Save
+person, err := gsdb.QuerySingleStruct[Person]("SELECT * FROM people WHERE id = ?", id)
+// If no row matches, person is the zero value and err is nil.
+```
+
+### Records and Fields
-If the primary key is zero, then an Insert is executed, otherwise it's an Update.
+`DB.Query` returns `[]Record`, where a `Record` is `map[string]Field`. A `Field` wraps the
+raw value and offers converters:
```go
- type InsertPerson struct {
- Id int `db:"column=id primarykey=yes table=Test"`
- Name string `db:"column=name"`
- Dtadded time.Time `db:"column=dtadded"`
- Status int `db:"column=status"`
- Ignored int `db:"column=ignored omit=yes"`
- }
-
- entry := InsertPerson{
- Name: "Test",
- Dtadded: time.Now(),
- Status: 1,
- }
-
- gsdb.New(DataSource, StructuredLoggingHandler, context)
-
- gsdb.Save(entry,0)
-
- ```
+records, err := gsdb.DB.Query("SELECT id, name, created FROM people")
+for _, r := range records {
+ id := r["id"].AsInt() // also AsInt64, AsUInt64, AsFloat, AsBool
+ name := r["name"].AsString() // also AsByte
+ created := r["created"].AsDate("zero") // "now", "zero", or a "2006-01-02 15:04:05" fallback
+ fmt.Println(id, name, created)
+}
+```
-### Counters
+`RecordInsert(record, table)` and `RecordUpdate(record, table, column, value)` write a
+`Record` back without needing a struct.
-You can start a counter anywhere in your call code, and then call the getCounter functions to see how many SQL statements have happened since that counter was started.
+### Counters (experimental)
-This is experimental at this stage.
+Count how many statements run between two points in your code:
```go
- MySQL.DB.StartCounter("test")
-
- // Loads of DB calls
-
- count := MySQL.DB.GetCounter("test)
+gsdb.DB.StartCounter("request")
+// ... lots of DB calls ...
+count := gsdb.DB.GetCounter("request")
```
-### ColumnWarnings
+Every `Execute` and `Query` increments all active counters.
-If the struct doesn't match the SQL returned or generated. The library will spit out a warning that there is a mismatch, and then ignore it. You can turn of this warning with
+## Configuration
+
+These package-level variables can be set before use:
+
+| Variable | Default | Effect |
+| --- | --- | --- |
+| `gsdb.ShowSQL` | `false` | Log every statement run through `Execute` (with last insert ID and rows affected) at `INFO`. |
+| `gsdb.ColumnWarnings` | `false` | Log a warning when a query returns a column that has no matching struct field. |
+| `gsdb.MaxOpenConns` | `50` | Max open connections (MySQL connections opened by `gsdb.New`). |
+| `gsdb.MaxIdleConns` | `50` | Max idle connections (MySQL). Idle connections are closed after 5 minutes. |
```go
- MySQL.ColumnWarnings = true
- P, _ := MySQL.QuerySingleStruct[InsertPerson]("select * from Test WHERE ID = ?", lastInsertedID)
+gsdb.ShowSQL = true
+gsdb.ColumnWarnings = true
+```
+
+## Trade-offs and caveats
+
+- **Reflection and memory.** GSDB uses reflection for type conversion and loads the whole
+ result set into memory. Paginate large queries.
+- **One global connection.** `gsdb.DB` is package-level; you can't hold two databases at once.
+- **Time zones.** Times are written as `YYYY-MM-DD HH:MM:SS` with no zone, and read back
+ with no zone (Go treats that as UTC). Store and compare times in UTC to avoid surprises.
+- **Generated SQL embeds values.** `Insert`/`Update`/`Save` put values directly into the SQL
+ text (strings hex-encoded, numbers and dates formatted). Use `Execute`/`Query` with `?`
+ parameters for anything you write by hand.
+- **Databases.** Only the MySQL and SQLite drivers are registered. The generated SQL (hex
+ literals, `true`/`false`) targets those two.
+- **`InsertMany` is not ready.** It currently joins rows without commas, which produces
+ invalid SQL for more than one row. Use `Insert` in a loop for now.
+
+## Project layout
+
+```text
+.
+├── gsdb/ # The library
+│ ├── New.go # Database type, New / NewSQLite3, connection pool settings
+│ ├── Execute.go # DB.Execute
+│ ├── Query.go # DB.Query -> []Record
+│ ├── QueryStruct.go # QueryStruct[T] / QuerySingleStruct[T]
+│ ├── Insert.go # DB.Insert, InsertMany
+│ ├── Update.go # DB.Update
+│ ├── Save.go # DB.Save (insert or update)
+│ ├── Record.go # Record type, RecordInsert / RecordUpdate
+│ ├── Fields.go # Field type and As* converters
+│ ├── counters.go # Statement counters
+│ ├── common.go # Tag parsing and SQL builders
+│ └── *_test.go # Unit tests (sqlmock) and SQLite integration tests
+├── examples/ # Runnable examples, called from main.go
+├── main.go # Runs all the examples
+└── docs/ # Longer guides
```
-If there are any fields defined in InsertPerson that don't match the record set, a warning is raised.
+How the pieces fit together is described in [docs/architecture.md](docs/architecture.md).
+
+## Running the examples
+
+The `examples/` directory has runnable programs for inserts, counters, missing columns,
+date defaults, `Save`, SQLite and concurrent inserts. See
+[examples/README.md](examples/README.md) for how to run them.
+
+## Status and roadmap
+
+GSDB is pre-1.0 (`v0.0.x`), so the API may still change between releases. See [CHANGELOG.md](CHANGELOG.md) and the
+[releases page](https://github.com/SpaceTent/GoStructDB/releases).
+
+Items noted in the code as not finished yet:
+
+- Counters are experimental.
+- Wider type coverage in `Query` (marked `TODO` in the source).
+- `InsertMany` (see caveats above).
+
+## Contributing
+
+Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) and our
+[Code of Conduct](CODE_OF_CONDUCT.md). To report a security issue, see
+[SECURITY.md](SECURITY.md).
-### ShowSQL
+## License
+GoStructDB is released under the [MIT License](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..a59f95e
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,38 @@
+# Security Policy
+
+## Supported versions
+
+GoStructDB is pre-1.0. Security fixes are made on `main` and released in the next
+`v0.0.x` tag. Please make sure you're on the [latest release](https://github.com/SpaceTent/GoStructDB/releases/latest)
+before reporting.
+
+| Version | Supported |
+| --- | --- |
+| Latest `v0.0.x` release | ✅ |
+| Older releases | ❌ |
+
+## Reporting a vulnerability
+
+**Please do not report security vulnerabilities through public GitHub issues, discussions
+or pull requests.**
+
+Report them privately using GitHub's
+[private vulnerability reporting](https://github.com/SpaceTent/GoStructDB/security/advisories/new)
+(**Security** tab → **Report a vulnerability**). If that isn't available, email
+**mark@ackroyd.net** with `[GoStructDB security]` in the subject.
+
+Please include:
+
+- A description of the issue and its impact (for example, SQL injection through generated SQL).
+- Steps or a minimal Go program to reproduce it, including struct tags and table schema.
+- The GSDB version, database and Go version.
+
+We'll acknowledge your report as soon as we can, keep you updated while we work on a fix,
+and credit you in the release notes unless you'd rather stay anonymous.
+
+## Scope notes
+
+`DB.Insert`, `DB.Update` and `DB.Save` build SQL text from struct values (strings are
+hex-encoded). Reports showing that a value can break out of that encoding are in scope.
+SQL you write yourself and pass to `Execute`/`Query` is your responsibility; use `?`
+parameters for untrusted input.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..5cba93a
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,86 @@
+# How GSDB works
+
+GSDB is a thin layer over `database/sql`. It has no query builder for `SELECT`s and no
+schema management. It does three jobs:
+
+1. **Holds a connection**: one global `*gsdb.Database` in `gsdb.DB`.
+2. **Reads rows** into `Record`s (maps of `Field`s), and from there into structs.
+3. **Writes structs** by generating `INSERT`/`UPDATE` SQL from `db` struct tags.
+
+```mermaid
+flowchart LR
+ subgraph App["Your code"]
+ S["Struct with db tags"]
+ Q["Hand-written SQL + ? params"]
+ end
+
+ subgraph GSDB["gsdb package"]
+ NEW["New / NewSQLite3
(global gsdb.DB)"]
+ INS["DB.Insert / DB.Update
(build SQL from tags)"]
+ SAVE["DB.Save
pk zero → Insert
pk set → Update"]
+ EXE["DB.Execute"]
+ QRY["DB.Query → []Record"]
+ QS["QueryStruct[T] / QuerySingleStruct[T]
(reflection: Record → T)"]
+ CNT["Counters"]
+ end
+
+ DBSQL["database/sql
+ mysql / sqlite3 drivers"]
+ DB[("MySQL / SQLite")]
+
+ S --> SAVE --> INS
+ S --> INS -->|SQL string| EXE
+ Q --> EXE
+ Q --> QRY
+ Q --> QS --> QRY
+ EXE --> DBSQL
+ QRY --> DBSQL
+ DBSQL --> DB
+ EXE -.increments.-> CNT
+ QRY -.increments.-> CNT
+ NEW --> DBSQL
+```
+
+## Connection (`New.go`)
+
+- `New(dsn, logger, ctx)` stores the DSN. The MySQL pool is opened lazily by
+ `getConnection()` on the first `Execute`/`Query`: it calls `sql.Open("mysql", dsn)` and
+ `Ping()`, retrying up to 3 times, then applies `MaxOpenConns`, `MaxIdleConns` and a
+ 5-minute idle timeout.
+- `NewSQLite3(file, logger, ctx)` opens the SQLite handle straight away and marks the
+ connection ready.
+- A mutex guards connection setup so concurrent first calls don't open several pools.
+
+## Reading (`Query.go`, `QueryStruct.go`, `Fields.go`)
+
+`DB.Query` scans every row into `[]interface{}` and builds a `Record`
+(`map[string]Field`) per row. `[]byte` values (how the MySQL driver returns most text and
+dates) are converted to `string`; other values are stored as returned by the driver.
+
+`QueryStruct[T]` calls `DB.Query`, then for every column:
+
+1. Finds the struct field whose `column=` tag matches the column name.
+2. Uses the field's Go type to pick a converter: `AsInt64`, `AsUInt64`, `AsBool`,
+ `AsFloat`, `AsString`, `AsDate(readdefault)` or `AsByte`.
+3. Sets the field via reflection.
+
+Columns with no matching field are skipped (with a warning when `ColumnWarnings` is on).
+`QuerySingleStruct[T]` returns the first result, or the zero `T` if there are none.
+
+## Writing (`Insert.go`, `Update.go`, `Save.go`, `common.go`)
+
+`decodeTag` turns `db:"column=id primarykey=yes table=people"` into a map. The SQL
+builders walk the struct's exported fields:
+
+- Fields with `omit=yes` or `primarykey=yes` are left out of the column list.
+- Values are formatted by type: numbers and bools as-is, strings as hex literals
+ (`X'...'`), times as `'YYYY-MM-DD HH:MM:SS'`, and zero times as `NULL` when
+ `readdefault=null`.
+- `Update` adds `WHERE =`.
+
+`Save(v, pk)` checks whether `pk` is the zero value and calls `Insert` or `Update`, then
+`Execute`.
+
+## Counters (`counters.go`)
+
+`StartCounter(name)` adds a named counter at 0. Every `Execute` and `Query` increments all
+existing counters, and `GetCounter(name)` reads one. Access is mutex-protected.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..6411207
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,132 @@
+# Getting started with GSDB
+
+This guide takes you from an empty folder to a working program that inserts, reads,
+updates and deletes rows with GSDB. It uses SQLite first (no server needed), then shows the
+changes for MySQL.
+
+## 1. Prerequisites
+
+- Go 1.26 or newer: `go version`
+- A C compiler for SQLite support (cgo): `gcc --version` or `clang --version`
+ - macOS: `xcode-select --install`
+ - Debian/Ubuntu: `sudo apt install build-essential`
+
+## 2. Create a module
+
+```bash
+mkdir gsdb-demo && cd gsdb-demo
+go mod init example.com/gsdb-demo
+go get github.com/SpaceTent/GoStructDB@latest
+```
+
+## 3. Describe your table as a struct
+
+```go
+type Person struct {
+ ID int `db:"column=id primarykey=yes table=people"`
+ Name string `db:"column=name"`
+ Email string `db:"column=email"`
+ Active bool `db:"column=active"`
+ Created time.Time `db:"column=created readdefault=zero"`
+ Notes string `db:"column=notes omit=yes"`
+}
+```
+
+- `column=` names the column. Every exported field needs one.
+- `table=` (on one field, usually the primary key) names the table for generated SQL.
+- `primarykey=yes` is skipped on `INSERT` and becomes the `WHERE` clause on `UPDATE`.
+- `omit=yes` is never written.
+- `readdefault=zero` returns the zero time when the column is `NULL` (without it, GSDB
+ returns `time.Now()` for backward compatibility).
+
+The full tag reference is in the [README](../README.md#struct-tags).
+
+## 4. Write the program
+
+Copy the [Quick start program](../README.md#quick-start) into `main.go`. It:
+
+1. Connects with `gsdb.NewSQLite3("quickstart.db", logger, ctx)`.
+2. Creates the `people` table with `gsdb.DB.Execute`.
+3. Inserts a `Person` with `gsdb.DB.Save(p, p.ID)` (ID is 0, so it's an `INSERT`).
+4. Loads it with `gsdb.QuerySingleStruct[Person]`.
+5. Changes the email and calls `Save` again (ID is now set, so it's an `UPDATE`).
+6. Prints the SQL `gsdb.DB.Update` would generate.
+7. Lists rows with `gsdb.QueryStruct[Person]` and with the untyped `gsdb.DB.Query`.
+
+## 5. Run it
+
+```bash
+go run .
+```
+
+```text
+inserted id: 1
+loaded: {ID:1 Name:Ada Lovelace Email:ada@example.com Active:true Created:2026-10-10 17:05:46 +0000 UTC Notes:}
+updated rows: 1
+generated SQL: UPDATE people SET name=X'416461204c6f76656c616365',email=X'61646140616e616c79746963616c2e656e67696e65',active=true,created='2026-10-10 17:05:46' WHERE id=1;
+active people: 1
+record: 1 Ada Lovelace
+```
+
+Run it again and you'll get `inserted id: 2`, because the database file persists.
+
+## 6. Turn on SQL logging while you develop
+
+```go
+gsdb.ShowSQL = true // log every Execute
+gsdb.ColumnWarnings = true // warn when a column has no matching struct field
+```
+
+With `ShowSQL` on, each statement is logged through your `slog.Logger`:
+
+```text
+level=INFO msg="INSERT INTO people(name,created) VALUES (X'78','2026-10-10 17:22:20');" lastid=1 "rows effected"=1
+```
+
+## 7. Delete rows
+
+There's no `Delete` helper; write the SQL and use parameters:
+
+```go
+_, rows, err := gsdb.DB.Execute("DELETE FROM people WHERE id = ?", ada.ID)
+```
+
+## 8. Switching to MySQL / MariaDB
+
+Only the connection line and the DDL change.
+
+```go
+gsdb.New("user:password@tcp(127.0.0.1:3306)/gsdb_demo", logger, context.Background())
+```
+
+```sql
+CREATE TABLE people (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(255),
+ email VARCHAR(255),
+ active BOOLEAN,
+ created DATETIME NULL
+);
+```
+
+Notes:
+
+- The DSN uses [go-sql-driver/mysql syntax](https://github.com/go-sql-driver/mysql#dsn-data-source-name).
+- `gsdb.New` doesn't connect straight away. The first `Execute`/`Query` opens the pool and
+ pings the server (up to 3 attempts, 500 ms apart).
+- Pool size comes from `gsdb.MaxOpenConns` / `gsdb.MaxIdleConns` (default 50). Set them
+ before the first query.
+
+A throwaway MySQL server for local testing, if you use Docker:
+
+```bash
+docker run --rm -d --name gsdb-mysql -p 3306:3306 \
+ -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=gsdb_demo mysql:8
+# DSN: root:secret@tcp(127.0.0.1:3306)/gsdb_demo
+```
+
+## Next steps
+
+- Try the runnable programs in [examples/](../examples/README.md).
+- Read [how GSDB works](architecture.md).
+- Check the [trade-offs and caveats](../README.md#trade-offs-and-caveats) before using it on large tables.
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..3039a81
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,81 @@
+# Examples
+
+Each file in this directory is a small function showing one GSDB feature. `main.go` in the
+repo root calls them all, in this order:
+
+| Function | File | Shows |
+| --- | --- | --- |
+| `BasicInsert` | `basic-Insert.go` | Build an `INSERT` from a struct with `DB.Insert`, run it with `DB.Execute` |
+| `BasicInsertWithCounter` | `basic-insert-withcounter.go` | `StartCounter` / `GetCounter` around 200 queries |
+| `ConcurrentInserts` | `Concurrent-Inserts.go` | 1,000 goroutines inserting, reading back and deleting |
+| `InsertDateDefault` | `Insert-DateDefault.go` | `readdefault=now`, `zero`, `null` and the default for `NULL` dates |
+| `MissingColumns` | `Missing-Columns.go` | Struct/table mismatches and `gsdb.ColumnWarnings` |
+| `Lite3` | `SQLite3.go` | `NewSQLite3` against `test.db`, creating the `Test` table |
+| `SaveReadDefaultNull` | `Save-DateDefault.go` | `DB.Save` insert then update with `readdefault=null` |
+
+All examples connect with `NewSQLite3` using the `DSN` environment variable (loaded from a
+`.env` file if present). The MySQL call `New(DSN, ...)` is left commented out in each file.
+
+## Run them against SQLite
+
+From the repository root:
+
+```bash
+# 1. Create the Test table the examples expect
+# (Lite3 creates it too, but it runs after the others).
+sqlite3 test.db "CREATE TABLE IF NOT EXISTS Test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, dtadded DATETIME DEFAULT CURRENT_TIMESTAMP, status INTEGER);"
+
+# 2. Run every example
+DSN=test.db go run main.go
+```
+
+No `sqlite3` CLI? Run `DSN=test.db go run main.go` twice; the first run creates the table
+(via `Lite3`) and the second run works.
+
+You can also put the DSN in a file instead of the environment. Copy `examples/env-dist`:
+`BasicInsert` reads `examples/.env`, and the other examples read `.env` in the current
+directory, so `DSN=...` in the environment is the simplest option.
+
+### What to expect
+
+You'll see `slog` lines such as:
+
+```text
+level=INFO msg="Item with ID 1 was inserted. 1 rows were affected"
+level=INFO msg="Number of SQL Queries" Counter=201
+level=INFO msg=Date "Date Should be NULL/zero"=0001-01-01T00:00:00.000Z
+level=INFO msg="Save insert rows: 1, save update rows: 1"
+```
+
+`ConcurrentInserts` is written with MySQL in mind. On SQLite, 1,000 concurrent writers
+produce many `database is locked` errors (and some `ID mismatch` lines); that is SQLite
+write locking, not a GSDB failure.
+
+`MissingColumns` deliberately prints a `WARN Unknown type col=status ...` line to show
+`ColumnWarnings`.
+
+## Run them against MySQL
+
+1. In each example, swap `MySQL.NewSQLite3(DSN, ...)` for the commented-out
+ `MySQL.New(DSN, ...)`.
+2. Create the table:
+
+ ```sql
+ CREATE TABLE Test (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(255),
+ dtadded DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+ status INT
+ );
+ ```
+
+3. Run with a go-sql-driver DSN:
+
+ ```bash
+ DSN='user:password@tcp(127.0.0.1:3306)/gsdb_examples' go run main.go
+ ```
+
+Some examples query `test` (lower case) while the table is `Test`. That is fine on SQLite
+and on MySQL with case-insensitive table names (the default on macOS and Windows). On Linux
+MySQL servers with `lower_case_table_names=0`, create the table as `test` too or adjust the
+queries.