Upgrading from 1.x
A task-oriented walkthrough of every breaking change between go-unifi 1.x and 2.0, with before/after code and grep hints.
This guide walks you through every breaking change introduced in go-unifi 2.0.0. It is written for Go developers of all experience levels: a senior can skim the Fast path checklist and be done in minutes; a junior can read each thematic section for the full story of what changed, why, and what to look for in their own code.
You don't need the breaking-changes log open while reading this — all the rationale and affected symbols are inlined here. That page is a cross-reference for completeness; this one is your hands-on guide.
Fast path
A mechanical checklist — tick each off in order:
- Update the import path to
/v2:github.com/filipowm/go-unifi/v2/unifi - Update
go get:go get github.com/filipowm/go-unifi/v2 - Replace
ClientConfig.User/Password/RememberMewithAPIKey: - Remove any calls to
.Login(),.LoginContext(),.Logout(),.LogoutContext() - Rename
VerifySSL: false→SkipVerifySSL: true(and double-check the logic flip — verify is now ON by default) - Update your Go toolchain to Go 1.26 or newer
- Replace
NewBareClient(cfg)withNewClient(cfg)+SkipSystemInfo: true - Review error checks on
Create/Updatepaths (they no longer returnErrNotFound) - If your code implements the
Clientinterface, addSetSetting,VersionContext, andGetSystemInformationContext - Move promoted logging calls (
c.Errorf(...)) to thec.Logger()accessor - Remove any direct use of
CSRFInterceptororCsrfHeader
Module path bumped to /v2
Every import path changes from github.com/filipowm/go-unifi/unifi to
github.com/filipowm/go-unifi/v2/unifi. This is a Go-modules requirement for a new major version.
// go-unifi 1.x
import "github.com/filipowm/go-unifi/unifi"// go-unifi 2.0.0
import "github.com/filipowm/go-unifi/v2/unifi"Update the module and run a find-and-replace across your tree:
go get github.com/filipowm/go-unifi/v2
find . -name '*.go' | xargs sed -i 's|filipowm/go-unifi/unifi|filipowm/go-unifi/v2/unifi|g'Authentication & TLS
API key replaces username/password
What changed. Username/password authentication is gone. The ClientConfig fields
User/Password/RememberMe and the type UserPassCredentials no longer exist. The
Login/LoginContext/Logout/LogoutContext methods are removed from the Client interface.
Old-style (classic) controllers — which only ever supported user/pass — are also unsupported; constructing
a client against one returns ErrOldStyleUnsupported immediately.
Why. API keys are more secure (no session expiry, no CSRF exposure, scoped credentials) and are the path Ubiquiti is investing in. Removing the fallback keeps the auth surface small and predictable.
// go-unifi 1.x — the field was always URL:, never BaseURL:
cfg := &unifi.ClientConfig{
URL: "https://unifi.localdomain",
User: "admin",
Password: "secret",
}// go-unifi 2.0.0
cfg := &unifi.ClientConfig{
URL: "https://unifi.localdomain",
APIKey: "your-api-key", // obtain from Control Plane → Admins & Users
}Check your code.
grep -r 'User:\|Password:\|RememberMe:\|UserPassCredentials' .
grep -r '\.Login(\|\.Logout(' .API keys require UniFi Network 9.0.114 or newer on a UniFi OS (new-style) controller. See Authentication for how to create one.
TLS verification now ON by default
What changed. ClientConfig.VerifySSL bool was renamed and inverted to SkipVerifySSL bool. The
zero value now means verify certificates (secure by default). In 1.x the zero value VerifySSL: false
meant skip verification — the exact opposite.
Why. The old name was a footgun: leaving it at zero gave you an insecure connection by accident. The renamed, inverted field means safe code requires no action; unsafe code is explicit and logged at WARN.
// go-unifi 1.x — zero value silently skipped verification
cfg := &unifi.ClientConfig{VerifySSL: false} // verification OFF// go-unifi 2.0.0 — zero value verifies; set true only for self-signed certs
cfg := &unifi.ClientConfig{SkipVerifySSL: true} // disable for self-signed cert (logs a warning)grep -r 'VerifySSL' . — any hit that set VerifySSL: false was getting no TLS verification and will now
get full verification (good for most callers). If your controller uses a self-signed cert you must add
SkipVerifySSL: true, or NewClient will fail with a TLS error.
CSRF handling removed
What changed. With username/password auth gone, the CSRFInterceptor and its token logic are removed.
The CsrfHeader constant is also gone.
Why. CSRF tokens were session-cookie artifacts tied to user/pass auth. API-key auth doesn't use session cookies, so CSRF management is irrelevant.
// go-unifi 1.x
c.AddInterceptor(&unifi.CSRFInterceptor{}) // type is gone in 2.0.0
// go-unifi 2.0.0 — simply remove the lineCheck your code. grep -r 'CSRFInterceptor\|CsrfHeader' .
Official API surface (additive, recommended)
The Official API (integration/v1) is the surface Ubiquiti now maintains as a versioned, stable contract,
and it's the forward-looking surface to reach for on new code where it covers the resource. But the choice
is per-resource and deliberate — the two surfaces use different identifiers and models, so don't
mechanically migrate every covered call. The Internal surface stays supported for everything the Official
API doesn't yet cover. See Choosing a surface for how to decide which
surface owns which resource.
What changed. c.Official() returns a fluent client for the UniFi official OpenAPI (integration/v1).
This is new in 2.0.0 and is purely additive — no existing code changes.
// Official API — new in 2.0.0; requires controller ≥ 10.1.78 with API-key auth
info, err := c.Official().Info().Get(ctx)
id, err := c.Official().Sites().ResolveID(ctx, "default") // returns uuid.UUID for the site
page, err := c.Official().Networks().ListPage(ctx, id, nil)
// body is an official.FirewallPolicyCreateOrUpdate
pol, err := c.Official().Firewall().CreatePolicy(ctx, id, body)All resource-group methods (Networks, Firewall, Devices, …) accept siteId uuid.UUID as their first
argument. Sites().ResolveID maps a familiar site name ("default", "lab", …) to the required UUID. If
you already have a UUID string, use uuid.Parse("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
Operations on c.Official() return ErrOfficialAPIUnavailable if the controller is below 10.1.78, is
old-style, or the GET /v1/info probe fails (a rejected API key surfaces here). Set DisableOfficialAPI: true
in ClientConfig to opt out entirely (operations then fail fast with ErrOfficialAPIDisabled).
Error handling. The Official surface uses the same error types as the Internal surface. Errors returned
through the default transport are *unifi.ServerError, and HTTP 404 responses satisfy
errors.Is(err, unifi.ErrNotFound):
id, err := c.Official().Sites().ResolveID(ctx, "default")
if errors.Is(err, unifi.ErrNotFound) {
// site not found
}
var serverErr *unifi.ServerError
if errors.As(err, &serverErr) {
log.Printf("controller error %d: %s", serverErr.StatusCode, serverErr.Message)
}In 2.0.0 the Internal surface remains the default — calling a resource method directly on c is identical
to calling it on c.Internal(). The default is expected to flip to Official in 3.0.0.
New to the two surfaces? Read Choosing a surface and the Official API guide.
Go version
What changed. The module requires Go 1.26 or later (go 1.26.0 in go.mod).
Why. The Official API surface uses range-over-func iterators (iter.Seq2), which need Go ≥ 1.23. The
module pins 1.26 as the project's supported toolchain baseline — not because any single language feature
demands it — so contributors and consumers build on one known-good floor.
Check your code. Run go version — if you're below 1.26 you'll see a build error immediately. Update
your toolchain:
go install golang.org/dl/go1.26.0@latest && go1.26.0 downloadClient interface additions
What changed. The Client interface has three new methods. If you maintain a custom type that
implements Client (rare — most callers use the concrete client returned by NewClient), you must add
these:
// Implement all three if you have a custom Client implementation
SetSetting(ctx context.Context, site string, key string, reqBody any) (any, error)
VersionContext(ctx context.Context) (string, error)
GetSystemInformationContext(ctx context.Context) (*SysInfo, error)LoginContext/LogoutContext were briefly added during the 2.0.0 work and then removed along with the rest
of username/password auth.
Why. SetSetting was on the concrete struct but missing from the interface. The *Context variants add
proper context propagation for callers who need cancellation/timeouts on lifecycle calls.
Check your code. This only affects you if you have a type that satisfies unifi.Client by listing the
methods (e.g. a hand-written mock). The generated ClientMock (moq) is regenerated automatically.
Logger is no longer promoted onto the client
What changed. The Client interface no longer embeds Logger; it exposes a single accessor
Logger() Logger. The 10 logging methods (Trace/Debug/Info/Warn/Error and their *f variants)
are therefore no longer promoted onto a Client value — call them through the accessor.
// go-unifi 1.x
c.Errorf("boom: %v", err)// go-unifi 2.0.0
c.Logger().Errorf("boom: %v", err)The Logger interface and the LoggingLevel enum are unchanged. The default logger is now backed by
log/slog; NewSlogLogger(*slog.Logger) Logger wraps a caller-supplied logger. See
Logging for the full picture.
Error handling
meta.rc=="error" on HTTP 200 now surfaces as *ServerError
What changed. The UniFi v1 API sometimes returns HTTP 200 with meta.rc == "error" — a soft
application-level failure. In 1.x this was caught in exactly one place (CreateUser) and silently swallowed
everywhere else. In 2.0.0 it is detected centrally, so every decoded {meta,data} 200 with
rc=="error" surfaces as a *ServerError carrying the controller's rc/msg, enriched with
status/method/URL.
Why. Silent swallowing of soft errors was a correctness hazard. Surfacing them uniformly lets callers make explicit decisions.
// Same code as before — but rc=="error" now produces *ServerError
n := &unifi.Network{Name: "lab"}
_, err := c.CreateNetwork(ctx, "default", n)
var serverErr *unifi.ServerError
if errors.As(err, &serverErr) {
log.Printf("controller error: %s", serverErr.Message)
}Check your code. If you have error checks that assumed success whenever err == nil after a resource
call, they are now correct. If you specifically checked for nil and then inspected the response body
yourself, you can remove that logic.
Requests that pass nil as the response body (all generated deletes and fire-and-forget cmd/
operations) skip this check — the body isn't inspected when no response struct is expected.
404 responses now satisfy errors.Is(err, ErrNotFound)
What changed. Previously only hand-written list/get wrappers returned ErrNotFound. In 2.0.0,
(*ServerError).Is maps any *ServerError with StatusCode == 404 to the ErrNotFound sentinel, so a
genuine HTTP 404 from any endpoint satisfies errors.Is(err, unifi.ErrNotFound).
Why. Consistent error semantics: "not found" means ErrNotFound, regardless of which endpoint produced
the 404.
// Same check; now covers all 404s, not just specific resources
_, err := c.GetNetwork(ctx, "default", "missing-id")
if errors.Is(err, unifi.ErrNotFound) {
// resource doesn't exist
}Check your code. If you were checking err != nil && !errors.Is(err, ErrNotFound) on get paths, the
behavior is now wider — any 404 will match. This is correct and desirable.
Create/Update no longer return ErrNotFound on unexpected responses
What changed. The v1-REST template used to return ErrNotFound when a successful create/update response
contained a data array with a length other than 1. That was semantically wrong (you just created something
— it can't "not be found"). In 2.0.0 these return a descriptive error instead:
fmt.Errorf("unexpected response: expected 1 <X>, got %d", n).
Why. ErrNotFound should only mean "the resource doesn't exist at that path." Using it on a create path
confused callers and hid real bugs.
// go-unifi 1.x — ErrNotFound could (incorrectly) come back from a create
_, err := c.CreateNetwork(ctx, "default", n)
if errors.Is(err, unifi.ErrNotFound) { /* this branch could fire on create */ }// go-unifi 2.0.0 — ErrNotFound will NOT come from Create/Update
_, err := c.CreateNetwork(ctx, "default", n)
// non-nil err means failure; errors.Is(err, ErrNotFound) is always false hereCheck your code. grep -r 'ErrNotFound' . — any errors.Is(err, ErrNotFound) that sits on a
Create/Update call path was dead code (the controller was returning an application error, not a 404).
Remove those branches or replace with a generic err != nil check.
Types and methods
NewBareClient replaced by NewClient with SkipSystemInfo: true
What changed. The NewBareClient function is removed. Its purpose was to create a client without the
eager GetSystemInformation() round-trip. You now do this with the standard NewClient and the
SkipSystemInfo: true field.
Why. A separate constructor created two code paths to maintain. The SkipSystemInfo field achieves the
same behaviour without the duplication.
// go-unifi 1.x
c, err := unifi.NewBareClient(&unifi.ClientConfig{
URL: "https://unifi.localdomain",
APIKey: "your-api-key",
})// go-unifi 2.0.0
c, err := unifi.NewClient(&unifi.ClientConfig{
URL: "https://unifi.localdomain",
APIKey: "your-api-key",
SkipSystemInfo: true, // skip the eager sysinfo check; errors surface on first API call
})Check your code. grep -r 'NewBareClient' .
New Patch method
What changed. The Client interface (and the concrete client) now exposes a Patch method alongside
the existing Get/Post/Put/Delete. It sends an HTTP PATCH request for partial updates.
Why. PATCH is the standard verb for partial updates, and the escape-hatch surface
(Do/Get/Post/Put/Delete) was missing it. It is most useful against endpoints that actually accept
PATCH — notably the Official integration/v1 surface and any new-style endpoint documented to support
partial updates.
// New in 2.0.0 — partial update via PATCH against a PATCH-capable endpoint
patch := struct {
Name string `json:"name"`
}{Name: "updated"}
var resp map[string]any
err := c.Patch(ctx, "/proxy/network/integration/v1/sites/<id>/...", patch, &resp)Patch sends a literal HTTP PATCH — the target endpoint must accept it. Most legacy Internal REST resources
(e.g. networkconf) expect PUT; use Put for those. This is an additive change — no existing code is
broken. See Raw HTTP requests for the full escape-hatch surface.
UseLocking is a no-op
What changed. ClientConfig.UseLocking is deprecated and has no effect. net/http.Client is
goroutine-safe; requests run concurrently and are not serialized.
Why. The old mutex serialisation was a performance hazard with no correctness benefit once the underlying HTTP client became goroutine-safe.
// go-unifi before 1.11.0 — serialised requests; a no-op since 1.11.0
cfg := &unifi.ClientConfig{UseLocking: true}// go-unifi 2.0.0 — field retained for source compat but ignored; remove it
cfg := &unifi.ClientConfig{} // concurrent by defaultCheck your code. grep -r 'UseLocking' . — you can remove the field; setting it has no effect. Share
one client across goroutines (see Concurrency).