Limitations
tanGO is still early. This page is the honest summary of where it stops, so you can decide whether that's fine for your project before investing in it. What each release promises not to break, and how breaking changes are announced, is in Versioning and compatibility.
Non-goals
- Only one relationship shape: many-to-one foreign keys. A field like
AuthorID int64 \tango:"fk=Author"`is the whole contract — see [relationships and admin foreign keys](guides/relationships-and-admin-foreign-keys.md), which together with schema validation, referential-integrity validation, and cascade delete makes up what tanGO calls its **minimal ORM foundations**: deliberately not a full ORM. No many-to-many, no reverse accessors (author.Posts), no eager/lazy loading, no automatic joins. If you need any of those, write the SQL yourself viaStore.Query/QueryRow— see [whereStore` stops](guides/relationships-and-admin-foreign-keys.md#where-store-stops-and-raw-sql-begins). - Composite unique/index constraints aren't supported. A field's
tango:"unique"/tango:"index"tag always describes that one column alone; there is no way yet to declare a composite constraint spanning multiple fields. This is a deliberate gap, not an oversight — if it's designed later, it will be a separate, non-tag, registration-time mechanism, not an extension of thefk=/unique/indextag grammar. - Admin's foreign key select has no raw-SQL escape hatch. Rendering an FK
<select>or a related-object label runs one query per row (N+1) — acceptable for the admin (already a non-optimized internal tool), but there's no way yet to hand-optimize a specific list page with a custom join. - Admin extensibility is deliberately narrow.
admin.Widget, theOptionspresentation fields, andadmin.WithBranding(see admin registration) cover per-field customization and two branding slots, and are best-effort (see what is not covered) — deliberately not inlines, admin actions, permission matrices, custom querysets, custom changelist views, or fullModelAdmin-style subclassing. None of those are planned; each would be its own future milestone if ever built. - Contributed migrations require explicit concatenation, not automatic discovery. A reusable app can ship its own
Migrationsvar, generated with a throwaway harness inside its own repo; the host project'smain.gomust explicitly concatenate it with the host's ownmigrations.Migrationsbefore callingDispatchFlags/ApplyPending/RollbackLast— tanGO never scans installed apps for migrations on its own. See reusable apps. - Renames must be declared, and only supported type changes are generated. Field and model renames are supported, but only when you declare them (
tango makemigrations --rename app.Model.Field=NewFieldor--rename app.Model=NewModel); tanGO never infers one, and an undeclared rename is refused as an unauthorised drop. A field's type can change only by a Widening type change (integertorealortext,realtotext,booleantointegerortext,varchar(n)totextor to a largervarchar) or by a narrowing of a string (texttovarchar(n), or a smallervarchar), which checks the data first and never truncates it (and needs a table with a single primary-key column, so a table with a composite key cannot be narrowed by a migration); any other type change, a change to which field is the primary key, or to a foreign key's target, makestango makemigrationsrefuse with an error naming each such field, and write nothing. tango shellruns an interpreter that knows an older Go than your project. It uses Yaegi, pinned at v0.16.1, which understands Go 1.21 and 1.22. Valid current Go can fail in it: themin,maxandclearbuiltins, inferring the type arguments of some generic functions, and ranging over a function or an integer. The shell reports these with a pointer to its list and keeps the session going, andhelp()shows the list. Values of your own types are supported across its helper boundary only as plain values, maps, slices and errors. It is a local developer tool that runs code with your project's credentials, not a sandbox or a remote administration surface. See the shell guide.- No
tango newproject/newappinteractive wizard. Both are non-interactive, single-shot scaffolding commands; there's no guided multi-step prompt flow.
Security boundaries
-
Admin has a real session-cookie login, bcrypt-hashed accounts, CSRF protection on every form, and rate-limited login attempts — but it is still an internal-tool admin, not a full production auth system. There's no account lockout (only rate limiting), no idle timeout or "remember me" (sessions have a fixed lifetime from login), no password reset via email, and no admin-UI account management (
tango admin create/resetpassword/deactivateis CLI-only, by design — see admin registration). It's suitable for a trusted, low-traffic internal tool behind TLS — not a public-facing admin panel; tanGO provides no network-layer protection of its own. -
The login rate limiter is in-memory and per-process. It resets on restart and isn't shared across multiple server instances behind a load balancer — a real but bounded gap for a single-process admin tool. Behind a reverse proxy, name the proxy with
WithTrustedProxiesor every user shares one bucket (see behind a reverse proxy). The same limiter, and the same bound, protects the optionalaccountsapp's login, registration and password-reset endpoints. Running more than one instance covers what that means and the workarounds. -
accounts' password reset and email verification are single-process and best-effort. Withaccounts.WithMail:- Queued in memory: emails wait in an in-memory outbox. A crash loses what's queued, a full outbox drops new emails (logging
tango.accounts.mail_dropped), and a failed send is logged, never retried. - Per process: the per-address cooldown is in-memory and per-process, like the login rate limiter.
- No email change: there's no email-change flow. Whoever changes
Account.Emailmust clearEmailVerifiedAt. - Fixed wording: emails are fixed English plain text.
- No CLI: there's no dedicated CLI for managing accounts either; the generic admin CRUD is the whole operational story, once you've manually registered
Accountwithadmin.
See the accounts guide. For more than one instance, see running more than one instance.
- Queued in memory: emails wait in an in-memory outbox. A crash loses what's queued, a full outbox drops new emails (logging
-
mailsends plain text to one recipient over SMTP.- Out of scope: HTML mail, inline images, streamed attachments, DKIM, queueing, retries and bounce handling.
- Attachments: they live in memory, 10 MiB of raw bytes per message by default.
- Trusted certificates: an SMTP server's certificate must chain to the system's trusted roots; there's no option for a private CA.
See the mail guide.
-
JWT access tokens are stateless and cannot be revoked before expiry.
auth/jwtperforms no database lookup, blacklist check, or account-status check during verification. There are no refresh tokens. Choose database-backed cookie sessions when immediate logout or deactivation must invalidate credentials; otherwise keep JWT lifetimes short and treat expiry as the only containment mechanism. See JWT authentication. -
JWT support is HS256-only with manual, fixed-set key rotation. There is no RS256/ES256, JWKS, external identity-provider verification, automatic rotation, or remote key loading. A deployment explicitly supplies one active signing key and any retired verification-only keys.
-
Query-string JWTs can leak through infrastructure logs.
jwt.QueryTokenexists for transports that genuinely cannot send anAuthorizationheader, but URLs may appear in browser history and server or proxy access logs. Preferjwt.BearerTokenwhenever possible. -
realtimerooms are single-process and in-memory only. There is no distributed pub/sub and no cross-instance presence — aHubbehind a load balancer with more than one server process does not coordinate rooms across processes. See running more than one instance for routing a room to one process. There is also no persistence or replay of room state across a restart or past a room's eviction, and no automatic game rules, bot AI, or matchmaking —Logicis entirely host-written. -
realtime's only backpressure policy is closing the slow peer. A peer whose outbound queue overflows is closed outright; there is no drop-oldest/drop-newest policy and no per-peer priority. Defaults —DefaultReconnectWindow(30s),DefaultPeerQueue(16),DefaultRoomQueue(64), andrealtime/websocket.DefaultMaxMessageSize(32 KiB) — are all overridable, and hosts are expected to tune them for their own traffic shape. See realtime and WebSockets. -
No framework-installed OS signal handling.
tango.ServeContextshuts down gracefully when itsctxis canceled, but never installs aSIGTERM/SIGINThandler itself — a host wiressignal.NotifyContextin its ownmain, as themain.gothattango newprojectgenerates andexamples/realtime-chatdo.Hub.Close(and any other component) is wired in viaRegistry.RegisterLifecycle, not a bespoke shutdown path — see application lifecycle. -
No process supervisor or restart policy. A crashed or exited process is not restarted by tanGO — that's left entirely to your process manager or container orchestrator.
-
-tango-statuscan't report whetherLifecyclecomponents are running. It runs as its own short-lived process that never starts them, so it can only truthfully report registration, database and migration state, and it does. Whether a component is running is only known inside the serving process; a live health endpoint for that is future work. -
Graceful shutdown may take up to roughly 2x
WithShutdownTimeout. HTTP draining and the entireLifecycle.Stoppass each get their own independent timeout budget, not one shared deadline — but within the stop phase, everyLifecycle.Stopcall shares that one budget, not a fresh one per component. Account for this when tuning a container's overall termination grace period. If the HTTP drain itself hits its deadline,Server.Close()is called and the drain-timeout error is still returned (errors.Is(err, context.DeadlineExceeded)) even though the forced close succeeded — shutdown was not fully graceful, and in-flight requests may have been aborted. -
View errors are never exposed to clients. A view returning an error always produces a generic 500 JSON body (a body limit's rejection gets
413instead); the real error is only logged server-side. This is a deliberate boundary, not a gap — but it also means you must return your own structured error responses (viactx.JSON) for anything a client legitimately needs to see. -
ratelimitis single-process, in-memory only. No distributed/shared-state quota across multiple server processes. It opportunistically forgets a key once that key's bucket has been idle long enough to have fully refilled anyway, so it doesn't retain every key ever seen forever — but this is a memory optimization, not a persistence or cross-process mechanism. No tenant-level policy engine or adaptive throttling. It is independent of, and not a replacement for,admin/accounts' existing failed-login-attempt limiter, which counts authentication failures rather than every request. See rate limiting and running more than one instance.
Dialect differences
See the SQLite, PostgreSQL and MySQL setup guide for the full list. In short: placeholder syntax, ALTER TABLE behavior (SQLite rebuilds a table to drop a column, keeping everything else about it; Postgres and MySQL alter directly), and timestamp column types differ. None of this should be visible in your model or migration code — only in what DDL actually runs, and in raw SQL you write yourself.
Irreversible migrations
A migration containing DropColumn, DropTable or a widening AlterColumnType is marked irreversible; one whose only type changes are string narrowings is reversible. tango migrate down on one fails explicitly with a clear error rather than attempting to restore data it has no way to recover. If you need to test a rollback path, do it in a disposable database before applying the same migration to data you care about.
Observability boundaries
- Automatic HTTP metrics and
AccessLoggerobserve matched routes, including middleware short-circuits and View errors. Router-generated 404 and 405 responses are observed only underConfig.MiddlewareScope: tango.MiddlewareScopeAll, whichtango newprojectsets. Under the default scope, they pass no global middleware and are not observed. See middleware scope. - Metrics are backend-neutral only. tanGO does not ship Prometheus/OpenTelemetry exporters, dashboards, distributed tracing, DB timing, or log shipping.
- Realtime and root HTTP/scheduler recorders are configured independently to preserve the package boundary.
Test coverage
Statement coverage is held above 90%, tracked via Codecov (see the badge on the README). CI uploads the test profile of each database. That profile cannot see the programs some tests build and run as a separate process (the scaffolded server and tango shell), so code only those tests reach, such as the interactive terminal in tango shell, shows as less covered than it is. scripts/coverage.sh counts those programs too, which adds about one point; it is not part of CI because instrumenting a project's whole dependency tree roughly doubled the test job for that gain.
scripts/coverage.sh # SQLite
TANGO_TEST_DSN=postgres://… scripts/coverage.sh # PostgreSQL
A small set of lines is deliberately never exercised by a unit test, because doing so would need a live Postgres connection or a real interactive terminal rather than a meaningful behavioral test — a small set of statements (the exact count depends on which database the suite runs against, so none is quoted):
db.Store.Create's PostgresRETURNING-based insert path (db/store.go) — only taken when bothdialect == db.Postgresand the model needs a backfilled default, and only actually reachable with a live Postgres connection (the SQLite-backed test suite, which is this repo's default, never exercises it). Exercised by a Postgres run of the suite (TANGO_TEST_DSN=postgres://…), not by the default in-memory SQLite coverage run.cmd/tango's entrypoint (cmd/tango/main.go) — a singleos.Exit(cli.Run(...))line;cli.Run's own dispatch logic is fully covered separately ininternal/cli.- The real-terminal check and Bubble Tea's own failure (
internal/cli/tui.go'sisInteractiveTerminal, and the error branch oftea.Program.Run()inrunDashboard) — the first asks the operating system whether stdin and stdout are a real terminal, which needs an actual TTY; the second reports a failure inside Bubble Tea that tanGO cannot provoke. Everything that depends on them is tested:tango tuitakes the terminal check as a parameter, and the dashboard's model, its actions and its whole event loop run under scripted keystrokes (see thetango tuiguide).
The rest of what is uncovered is ordinary, lower-priority code — mostly database/sql driver-failure branches (sql.Result.RowsAffected() erroring, sql.Rows.Scan()/.Columns() erroring, tx.Commit() failing), SQLite introspection errors in the migration rewrites, and a few stdlib-guaranteed-safe error checks. It was deliberately not chased once the 90% target was met, per this project's own design principle against writing tests for impossible or low-value branches purely to inflate a metric.