juanfont/headscale · critical

saving user: %w

Error message

saving user: %w

What it means

Migration 202505141324 failed saving a user after applying CleanIdentifier to the provider identifier. tx.Save(user) rewrites the full row, so failure can be the save itself (lock, connection) or a constraint violation: the unique partial index on provider_identifier rejects the cleaned value because another user already owns it.

Source

Thrown at hscontrol/db/db.go:233

				},
				Rollback: func(db *gorm.DB) error { return nil },
			},
			// Fix the provider identifier for users that have a double slash in the
			// provider identifier.
			{
				ID: "202505141324",
				Migrate: func(tx *gorm.DB) error {
					users, err := ListUsers(tx, nil)
					if err != nil {
						return fmt.Errorf("listing users: %w", err)
					}

					for _, user := range users {
						user.ProviderIdentifier.String = types.CleanIdentifier(user.ProviderIdentifier.String)

						err := tx.Save(user).Error
						if err != nil {
							return fmt.Errorf("saving user: %w", err)
						}
					}

					return nil
				},
				Rollback: func(db *gorm.DB) error { return nil },
			},
			// v0.27.0
			// Schema migration to ensure all tables match the expected schema.
			// This migration recreates all tables to match the exact structure in schema.sql,
			// preserving all data during the process.
			// Only SQLite will be migrated for consistency.
			{
				ID: "202507021200",
				Migrate: func(tx *gorm.DB) error {
					// Only run on SQLite
					if cfg.Database.Type != types.DatabaseSqlite {
						log.Info().Msg("skipping schema migration on non-SQLite database")

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Check the wrapped error for a unique-constraint violation on provider_identifier.
  2. If duplicate users result from the cleanup, merge or delete the redundant user in the source IdP / database, then re-run.
  3. For transient errors, retry the migration - each Save is independent within the transaction.
  4. Always back up the DB before upgrading past v0.25 so this migration can be re-attempted.
Defensive patterns

Strategy: try-catch

Validate before calling

-- Pre-upgrade check on a backup copy: will cleaning collide?
SELECT provider_identifier, COUNT(*) FROM users
WHERE provider_identifier LIKE '%//%'
GROUP BY provider_identifier;

Try / catch

if _, err := db.NewHeadscaleDatabase(cfg); err != nil {
    if strings.Contains(err.Error(), "saving user") && strings.Contains(err.Error(), "UNIQUE") {
        // identifier collision after cleaning: restore backup, merge duplicate IdP accounts, retry
    }
}

Prevention

When it happens

Trigger: Two users whose identifiers differ only by duplicate slashes (e.g. 'https://idp//a' and 'https://idp/a') - cleaning both produces identical values, violating idx_provider_identifier. Also plain connection/lock failures mid-loop.

Common situations: OIDC providers that changed their identifier format over time, creating near-duplicate accounts that collapse after cleaning.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/02307f427c741286. Report an issue: GitHub.