Repository: opentofu/opentofu
Pull request #2465: Make table name and index name a config value for PG backend
Author: login: taliesins
Created: 2025-02-01T00:30:10Z | Merged: 2025-04-09T21:21:25Z

Make it possible to specify the table name and index name when using PG backend. 

This makes it easier to use PG backend provider for scenarios such as using Terragrunt with convention based state storage per a `terragrunt.hcl` file by providing an additional layer of grouping.

Having many tables vs many schemas could be preferred by some users and the tools they use too.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ced99c75c33..d86b8cceaf2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ NEW FEATURES:
     - `provider::terraform::encode_tfvars` - Encode an object into a string with the same format as a TFVars file.
     - `provider::terraform::encode_expr` - Encode an arbitrary expression into a string with valid OpenTofu syntax.
 - Added support for S3 native locking ([#599](https://github.com/opentofu/opentofu/issues/599))
+- Backend `pg` now allows the `table_name` and `index_name` to be specified. This enables a single database schema to support multiple backends via multiple tables. ([#2465](https://github.com/opentofu/opentofu/pull/2465))
 
 ENHANCEMENTS:
 
@@ -45,7 +46,7 @@ BUG FIXES:
 - Changing Go version to 1.22.11 in order to fix [CVE-2024-45336](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-45336) and [CVE-2024-45341](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-45341) ([#2438](https://github.com/opentofu/opentofu/pull/2438))
 - Fix the error message when default value of a complex variable is containing a wrong type ([2394](https://github.com/opentofu/opentofu/issues/2394))
 - Fix the way OpenTofu downloads a module that is sourced from a GitHub branch containing slashes in the name. ([2396](https://github.com/opentofu/opentofu/issues/2396))
-- `pg` backend doesn't fail on workspace creation for paralel runs, when the database is shared across multiple projects. ([#2411](https://github.com/opentofu/opentofu/pull/2411))
+- `pg` backend doesn't fail on workspace creation for parallel runs, when the database is shared across multiple projects. ([#2411](https://github.com/opentofu/opentofu/pull/2411))
 - Generating an OpenTofu configuration from an `import` block that is referencing a resource with nested attributes now works correctly, instead of giving an error that the nested computed attribute is required. ([#2372](https://github.com/opentofu/opentofu/issues/2372)) 
 - `base64gunzip` now doesn't expose sensitive values if it fails during the base64 decoding. ([#2503](https://github.com/opentofu/opentofu/pull/2503))
 - Fix loading only the necessary encryption key providers and methods for better `terraform_remote_state` support. ([2551](https://github.com/opentofu/opentofu/issues/2551))
diff --git a/internal/backend/remote-state/pg/backend.go b/internal/backend/remote-state/pg/backend.go
index 0c61e8f004e..f53f2e78df8 100644
--- a/internal/backend/remote-state/pg/backend.go
+++ b/internal/backend/remote-state/pg/backend.go
@@ -9,20 +9,15 @@ import (
 	"context"
 	"database/sql"
 	"fmt"
+	"github.com/lib/pq"
 	"os"
 	"strconv"
 
-	"github.com/lib/pq"
 	"github.com/opentofu/opentofu/internal/backend"
 	"github.com/opentofu/opentofu/internal/encryption"
 	"github.com/opentofu/opentofu/internal/legacy/helper/schema"
 )
 
-const (
-	statesTableName = "states"
-	statesIndexName = "states_by_name"
-)
-
 func defaultBoolFunc(k string, dv bool) schema.SchemaDefaultFunc {
 	return func() (interface{}, error) {
 		if v := os.Getenv(k); v != "" {
@@ -58,6 +53,13 @@ func New(enc encryption.StateEncryption) backend.Backend {
 				DefaultFunc: defaultBoolFunc("PG_SKIP_SCHEMA_CREATION", false),
 			},
 
+			"table_name": {
+				Type:        schema.TypeString,
+				Optional:    true,
+				Description: "Name of the automatically managed Postgres table to store state",
+				DefaultFunc: schema.EnvDefaultFunc("PG_TABLE_NAME", "states"),
+			},
+
 			"skip_table_creation": {
 				Type:        schema.TypeBool,
 				Optional:    true,
@@ -65,6 +67,13 @@ func New(enc encryption.StateEncryption) backend.Backend {
 				DefaultFunc: defaultBoolFunc("PG_SKIP_TABLE_CREATION", false),
 			},
 
+			"index_name": {
+				Type:        schema.TypeString,
+				Optional:    true,
+				Description: "Name of the automatically managed Postgres index used for stored state",
+				DefaultFunc: schema.EnvDefaultFunc("PG_INDEX_NAME", "states_by_name"),
+			},
+
 			"skip_index_creation": {
 				Type:        schema.TypeBool,
 				Optional:    true,
@@ -88,6 +97,8 @@ type Backend struct {
 	configData *schema.ResourceData
 	connStr    string
 	schemaName string
+	tableName  string
+	indexName  string
 }
 
 func (b *Backend) configure(ctx context.Context) error {
@@ -96,7 +107,12 @@ func (b *Backend) configure(ctx context.Context) error {
 	data := b.configData
 
 	b.connStr = data.Get("conn_str").(string)
-	b.schemaName = pq.QuoteIdentifier(data.Get("schema_name").(string))
+	b.schemaName = data.Get("schema_name").(string)
+	b.tableName = data.Get("table_name").(string)
+	b.indexName = data.Get("index_name").(string)
+	skipSchemaCreation := data.Get("skip_schema_creation").(bool)
+	skipTableCreation := data.Get("skip_table_creation").(bool)
+	skipIndexCreation := data.Get("skip_index_creation").(bool)
 
 	db, err := sql.Open("postgres", b.connStr)
 	if err != nil {
@@ -106,11 +122,11 @@ func (b *Backend) configure(ctx context.Context) error {
 	// Prepare database schema, tables, & indexes.
 	var query string
 
-	if !data.Get("skip_schema_creation").(bool) {
+	if !skipSchemaCreation {
 		// list all schemas to see if it exists
 		var count int
 		query = `select count(1) from information_schema.schemata where schema_name = $1`
-		if err := db.QueryRow(query, data.Get("schema_name").(string)).Scan(&count); err != nil {
+		if err = db.QueryRow(query, b.schemaName).Scan(&count); err != nil {
 			return err
 		}
 
@@ -119,31 +135,33 @@ func (b *Backend) configure(ctx context.Context) error {
 		// a user hasn't been granted the `CREATE SCHEMA` privilege
 		if count < 1 {
 			// tries to create the schema
-			query = `CREATE SCHEMA IF NOT EXISTS %s`
-			if _, err := db.Exec(fmt.Sprintf(query, b.schemaName)); err != nil {
+			query = fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS %s`, pq.QuoteIdentifier(b.schemaName))
+			if _, err = db.Exec(query); err != nil {
 				return err
 			}
 		}
 	}
 
-	if !data.Get("skip_table_creation").(bool) {
-		if _, err := db.Exec("CREATE SEQUENCE IF NOT EXISTS public.global_states_id_seq AS bigint"); err != nil {
+	if !skipTableCreation {
+		query = "CREATE SEQUENCE IF NOT EXISTS public.global_states_id_seq AS bigint"
+		if _, err = db.Exec(query); err != nil {
 			return err
 		}
 
-		query = `CREATE TABLE IF NOT EXISTS %s.%s (
+		query = fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.%s (
 			id bigint NOT NULL DEFAULT nextval('public.global_states_id_seq') PRIMARY KEY,
 			name text UNIQUE,
 			data text
-			)`
-		if _, err := db.Exec(fmt.Sprintf(query, b.schemaName, statesTableName)); err != nil {
+			)`, pq.QuoteIdentifier(b.schemaName), pq.QuoteIdentifier(b.tableName))
+
+		if _, err = db.Exec(query); err != nil {
 			return err
 		}
 	}
 
-	if !data.Get("skip_index_creation").(bool) {
-		query = `CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s.%s (name)`
-		if _, err := db.Exec(fmt.Sprintf(query, statesIndexName, b.schemaName, statesTableName)); err != nil {
+	if !skipIndexCreation {
+		query = fmt.Sprintf(`CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s.%s (name)`, pq.QuoteIdentifier(b.indexName), pq.QuoteIdentifier(b.schemaName), pq.QuoteIdentifier(b.tableName))
+		if _, err = db.Exec(query); err != nil {
 			return err
 		}
 	}
diff --git a/internal/backend/remote-state/pg/backend_state.go b/internal/backend/remote-state/pg/backend_state.go
index 3bae6679c42..f7103bdad8c 100644
--- a/internal/backend/remote-state/pg/backend_state.go
+++ b/internal/backend/remote-state/pg/backend_state.go
@@ -7,6 +7,7 @@ package pg
 
 import (
 	"fmt"
+	"github.com/lib/pq"
 
 	"github.com/opentofu/opentofu/internal/backend"
 	"github.com/opentofu/opentofu/internal/states"
@@ -15,8 +16,8 @@ import (
 )
 
 func (b *Backend) Workspaces() ([]string, error) {
-	query := `SELECT name FROM %s.%s WHERE name != 'default' ORDER BY name`
-	rows, err := b.db.Query(fmt.Sprintf(query, b.schemaName, statesTableName))
+	query := fmt.Sprintf(`SELECT name FROM %s.%s WHERE name != 'default' ORDER BY name`, pq.QuoteIdentifier(b.schemaName), pq.QuoteIdentifier(b.tableName))
+	rows, err := b.db.Query(query)
 	if err != nil {
 		return nil, err
 	}
@@ -45,8 +46,8 @@ func (b *Backend) DeleteWorkspace(name string, _ bool) error {
 		return fmt.Errorf("can't delete default state")
 	}
 
-	query := `DELETE FROM %s.%s WHERE name = $1`
-	_, err := b.db.Exec(fmt.Sprintf(query, b.schemaName, statesTableName), name)
+	query := fmt.Sprintf(`DELETE FROM %s.%s WHERE name = $1`, pq.QuoteIdentifier(b.schemaName), pq.QuoteIdentifier(b.tableName))
+	_, err := b.db.Exec(query, name)
 	if err != nil {
 		return err
 	}
@@ -61,6 +62,8 @@ func (b *Backend) StateMgr(name string) (statemgr.Full, error) {
 			Client:     b.db,
 			Name:       name,
 			SchemaName: b.schemaName,
+			TableName:  b.tableName,
+			IndexName:  b.indexName,
 		},
 		b.encryption,
 	)
diff --git a/internal/backend/remote-state/pg/backend_test.go b/internal/backend/remote-state/pg/backend_test.go
index e2a5bdb4194..ca1829f9220 100644
--- a/internal/backend/remote-state/pg/backend_test.go
+++ b/internal/backend/remote-state/pg/backend_test.go
@@ -76,6 +76,31 @@ func TestBackendConfig(t *testing.T) {
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
 			},
 		},
+		{
+			Name: "valid-config-with-table-name",
+			Config: map[string]interface{}{
+				"conn_str":    connStr,
+				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+			},
+		},
+		{
+			Name: "valid-config-with-index-name",
+			Config: map[string]interface{}{
+				"conn_str":    connStr,
+				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
+			},
+		},
+		{
+			Name: "valid-config-with-table-name-and-index-name",
+			Config: map[string]interface{}{
+				"conn_str":    connStr,
+				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+			},
+		},
 		{
 			Name: "missing-conn_str-defaults-to-localhost",
 			EnvVars: map[string]string{
@@ -86,6 +111,8 @@ func TestBackendConfig(t *testing.T) {
 			},
 			Config: map[string]interface{}{
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
 			},
 		},
 		{
@@ -95,6 +122,8 @@ func TestBackendConfig(t *testing.T) {
 			},
 			Config: map[string]interface{}{
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
 			},
 		},
 		{
@@ -106,6 +135,8 @@ func TestBackendConfig(t *testing.T) {
 			Config: map[string]interface{}{
 				"conn_str":    connectionURIObfuscated.String(),
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
 			},
 			ExpectConnectionError: `authentication failed for user "baduser"`,
 		},
@@ -116,6 +147,8 @@ func TestBackendConfig(t *testing.T) {
 			},
 			Config: map[string]interface{}{
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
 			},
 			ExpectConnectionError: `no such host`,
 		},
@@ -131,6 +164,8 @@ func TestBackendConfig(t *testing.T) {
 			Config: map[string]interface{}{
 				"conn_str":    connStr,
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
 			},
 		},
 		{
@@ -142,6 +177,8 @@ func TestBackendConfig(t *testing.T) {
 			},
 			Config: map[string]interface{}{
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
 			},
 			ExpectConfigurationError: `error getting default for "skip_schema_creation"`,
 		},
@@ -154,13 +191,6 @@ func TestBackendConfig(t *testing.T) {
 			}
 
 			config := backend.TestWrapConfig(tc.Config)
-			schemaName := pq.QuoteIdentifier(tc.Config["schema_name"].(string))
-
-			dbCleaner, err := sql.Open("postgres", connStr)
-			if err != nil {
-				t.Fatal(err)
-			}
-			defer dropSchemaByQuotedName(t, dbCleaner, schemaName)
 
 			var diags tfdiags.Diagnostics
 			b := New(encryption.StateEncryptionDisabled()).(*Backend)
@@ -205,10 +235,62 @@ func TestBackendConfig(t *testing.T) {
 				t.Fatal("Backend could not be configured")
 			}
 
-			_, err = b.db.Query(fmt.Sprintf("SELECT name, data FROM %s.%s LIMIT 1", schemaName, statesTableName))
+			schemaName := b.Config().Get("schema_name").(string)
+			tableName := b.Config().Get("table_name").(string)
+			indexName := b.Config().Get("index_name").(string)
+			skipSchemaCreation := b.Config().Get("skip_schema_creation").(bool)
+			skipTableCreation := b.Config().Get("skip_table_creation").(bool)
+			skipIndexCreation := b.Config().Get("skip_index_creation").(bool)
+
+			dbCleaner, err := sql.Open("postgres", connStr)
 			if err != nil {
 				t.Fatal(err)
 			}
+			defer dropSchema(t, dbCleaner, schemaName)
+
+			// Make sure everything has been created
+			if skipSchemaCreation {
+				// Make sure schema exists
+				var count int
+				query := `select count(*) from information_schema.schemata where schema_name=$1`
+				if err = b.db.QueryRow(query, schemaName).Scan(&count); err != nil {
+					t.Fatal(err)
+				}
+
+				if count != 1 {
+					t.Fatalf("The schema has not been created (%d)", count)
+				}
+			}
+
+			if skipTableCreation {
+				// Make sure that the index exists
+				var count int
+
+				query := `select count(*) from pg_catalog.pg_tables where schemaname=$1 and tablename=$2;`
+				err = b.db.QueryRow(query, schemaName, tableName).Scan(&count)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				if count != 1 {
+					t.Fatalf("The table has not been created (%d)", count)
+				}
+			}
+
+			if skipIndexCreation {
+				// Make sure that the index exists
+				var count int
+
+				query := `select count(*) from pg_indexes where schemaname=$1 and tablename=$2 and indexname=$3;`
+				err = b.db.QueryRow(query, schemaName, tableName, indexName+"_name_key").Scan(&count)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				if count != 1 {
+					t.Fatalf("The index has not been created (%d)", count)
+				}
+			}
 
 			_, err = b.StateMgr(backend.DefaultStateName)
 			if err != nil {
@@ -219,6 +301,7 @@ func TestBackendConfig(t *testing.T) {
 			if err != nil {
 				t.Fatal(err)
 			}
+
 			c := s.(*remote.State).Client.(*RemoteClient)
 			if c.Name != backend.DefaultStateName {
 				t.Fatal("RemoteClient name is not configured")
@@ -235,64 +318,84 @@ func TestBackendConfigSkipOptions(t *testing.T) {
 	connStr := getDatabaseUrl()
 
 	testCases := []struct {
-		Name               string
-		SkipSchemaCreation bool
-		SkipTableCreation  bool
-		SkipIndexCreation  bool
-		TestIndexIsPresent bool
-		Setup              func(t *testing.T, db *sql.DB, schemaName string)
+		Name                string
+		SkipSchemaCreation  bool
+		SkipTableCreation   bool
+		SkipIndexCreation   bool
+		TestSchemaIsPresent bool
+		TestTableIsPresent  bool
+		TestIndexIsPresent  bool
+		Setup               func(t *testing.T, db *sql.DB, schemaName string, tableName string, indexName string)
 	}{
 		{
-			Name:               "skip_schema_creation",
-			SkipSchemaCreation: true,
-			TestIndexIsPresent: true,
-			Setup: func(t *testing.T, db *sql.DB, schemaName string) {
+			Name:                "skip_schema_creation",
+			SkipSchemaCreation:  true,
+			SkipTableCreation:   false,
+			SkipIndexCreation:   false,
+			TestSchemaIsPresent: true,
+			TestTableIsPresent:  true,
+			TestIndexIsPresent:  true,
+			Setup: func(t *testing.T, db *sql.DB, schemaName string, tableName string, indexName string) {
 				// create the schema as a prerequisites
-				_, err := db.Query(fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS %s`, schemaName))
+				query := fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS %s`, pq.QuoteIdentifier(schemaName))
+				_, err := db.Exec(query)
 				if err != nil {
 					t.Fatal(err)
 				}
 			},
 		},
 		{
-			Name:               "skip_table_creation",
-			SkipTableCreation:  true,
-			TestIndexIsPresent: true,
-			Setup: func(t *testing.T, db *sql.DB, schemaName string) {
+			Name:                "skip_table_creation",
+			SkipSchemaCreation:  true,
+			SkipTableCreation:   true,
+			SkipIndexCreation:   false,
+			TestSchemaIsPresent: true,
+			TestTableIsPresent:  true,
+			TestIndexIsPresent:  true,
+			Setup: func(t *testing.T, db *sql.DB, schemaName string, tableName string, indexName string) {
 				// since the table needs to be already created the schema must be too
-				_, err := db.Query(fmt.Sprintf(`CREATE SCHEMA %s`, schemaName))
+				query := fmt.Sprintf(`CREATE SCHEMA %s`, pq.QuoteIdentifier(schemaName))
+				_, err := db.Exec(query)
 				if err != nil {
 					t.Fatal(err)
 				}
-				_, err = db.Query(fmt.Sprintf(`CREATE TABLE %s.%s (
+				query = fmt.Sprintf(`CREATE TABLE %s.%s (
 					id SERIAL PRIMARY KEY,
-					name TEXT,
+					name text UNIQUE,
 					data TEXT
-					)`, schemaName, statesTableName))
+					)`, pq.QuoteIdentifier(schemaName), pq.QuoteIdentifier(tableName))
+				_, err = db.Exec(query)
 				if err != nil {
 					t.Fatal(err)
 				}
 			},
 		},
 		{
-			Name:               "skip_index_creation",
-			SkipIndexCreation:  true,
-			TestIndexIsPresent: true,
-			Setup: func(t *testing.T, db *sql.DB, schemaName string) {
+			Name:                "skip_index_creation",
+			SkipSchemaCreation:  true,
+			SkipTableCreation:   true,
+			SkipIndexCreation:   true,
+			TestSchemaIsPresent: true,
+			TestTableIsPresent:  true,
+			TestIndexIsPresent:  true,
+			Setup: func(t *testing.T, db *sql.DB, schemaName string, tableName string, indexName string) {
 				// Everything need to exists for the index to be created
-				_, err := db.Query(fmt.Sprintf(`CREATE SCHEMA %s`, schemaName))
+				query := fmt.Sprintf(`CREATE SCHEMA %s`, pq.QuoteIdentifier(schemaName))
+				_, err := db.Exec(query)
 				if err != nil {
 					t.Fatal(err)
 				}
-				_, err = db.Query(fmt.Sprintf(`CREATE TABLE %s.%s (
+				query = fmt.Sprintf(`CREATE TABLE %s.%s (
 					id SERIAL PRIMARY KEY,
-					name TEXT,
+					name text UNIQUE,
 					data TEXT
-					)`, schemaName, statesTableName))
+					)`, pq.QuoteIdentifier(schemaName), pq.QuoteIdentifier(tableName))
+				_, err = db.Exec(query)
 				if err != nil {
 					t.Fatal(err)
 				}
-				_, err = db.Exec(fmt.Sprintf(`CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s.%s (name)`, statesIndexName, schemaName, statesTableName))
+				query = fmt.Sprintf(`CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s.%s (name)`, pq.QuoteIdentifier(indexName), pq.QuoteIdentifier(schemaName), pq.QuoteIdentifier(tableName))
+				_, err = db.Exec(query)
 				if err != nil {
 					t.Fatal(err)
 				}
@@ -307,45 +410,75 @@ func TestBackendConfigSkipOptions(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.Name, func(t *testing.T) {
 			schemaName := tc.Name
+			tableName := tc.Name
+			indexName := tc.Name
 
 			config := backend.TestWrapConfig(map[string]interface{}{
 				"conn_str":             connStr,
 				"schema_name":          schemaName,
+				"table_name":           tableName,
+				"index_name":           indexName,
 				"skip_schema_creation": tc.SkipSchemaCreation,
 				"skip_table_creation":  tc.SkipTableCreation,
 				"skip_index_creation":  tc.SkipIndexCreation,
 			})
-			schemaName = pq.QuoteIdentifier(schemaName)
+
 			db, err := sql.Open("postgres", connStr)
 			if err != nil {
 				t.Fatal(err)
 			}
 
 			if tc.Setup != nil {
-				tc.Setup(t, db, schemaName)
+				tc.Setup(t, db, schemaName, tableName, indexName)
 			}
-			defer dropSchemaByQuotedName(t, db, schemaName)
+			defer dropSchema(t, db, schemaName)
 
-			b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
+			unconfiguredBackend := New(encryption.StateEncryptionDisabled())
+			b := backend.TestBackendConfig(t, unconfiguredBackend, config).(*Backend)
 
 			if b == nil {
 				t.Fatal("Backend could not be configured")
 			}
 
 			// Make sure everything has been created
+			if tc.TestSchemaIsPresent {
+				// Make sure schema exists
+				var count int
+				query := `select count(*) from information_schema.schemata where schema_name=$1`
+				if err = b.db.QueryRow(query, schemaName).Scan(&count); err != nil {
+					t.Fatal(err)
+				}
 
-			// This tests that both the schema and the table have been created
-			_, err = b.db.Query(fmt.Sprintf("SELECT name, data FROM %s.%s LIMIT 1", schemaName, statesTableName))
-			if err != nil {
-				t.Fatal(err)
+				if count != 1 {
+					t.Fatalf("The schema has not been created (%d)", count)
+				}
+			}
+
+			if tc.TestTableIsPresent {
+				// Make sure that the index exists
+				var count int
+
+				query := `select count(*) from pg_catalog.pg_tables where schemaname=$1 and tablename=$2;`
+				err = b.db.QueryRow(query, schemaName, tableName).Scan(&count)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				if count != 1 {
+					t.Fatalf("The table has not been created (%d)", count)
+				}
 			}
+
 			if tc.TestIndexIsPresent {
 				// Make sure that the index exists
-				query := `select count(*) from pg_indexes where schemaname=$1 and tablename=$2 and indexname=$3;`
 				var count int
-				if err := b.db.QueryRow(query, tc.Name, statesTableName, statesIndexName).Scan(&count); err != nil {
+
+				query := `select count(*) from pg_indexes where schemaname=$1 and tablename=$2 and indexname=$3;`
+				err = b.db.QueryRow(query, schemaName, tableName, indexName+"_name_key").Scan(&count)
+				if err != nil {
 					t.Fatal(err)
 				}
+
 				if count != 1 {
 					t.Fatalf("The index has not been created (%d)", count)
 				}
@@ -366,17 +499,18 @@ func TestBackendConfigSkipOptions(t *testing.T) {
 			}
 
 			// Make sure that all workspace must have a unique name
-			_, err = db.Exec(fmt.Sprintf(`INSERT INTO %s.%s VALUES (100, 'unique_name_test', '')`, schemaName, statesTableName))
+			query := fmt.Sprintf(`INSERT INTO %s.%s VALUES (100, 'unique_name_test', '')`, pq.QuoteIdentifier(schemaName), pq.QuoteIdentifier(tableName))
+			_, err = db.Exec(query)
 			if err != nil {
 				t.Fatal(err)
 			}
-			_, err = db.Exec(fmt.Sprintf(`INSERT INTO %s.%s VALUES (101, 'unique_name_test', '')`, schemaName, statesTableName))
+			query = fmt.Sprintf(`INSERT INTO %s.%s VALUES (101, 'unique_name_test', '')`, pq.QuoteIdentifier(schemaName), pq.QuoteIdentifier(tableName))
+			_, err = db.Exec(query)
 			if err == nil {
 				t.Fatal("Creating two workspaces with the same name did not raise an error")
 			}
 		})
 	}
-
 }
 
 func TestBackendStates(t *testing.T) {
@@ -387,8 +521,11 @@ func TestBackendStates(t *testing.T) {
 		fmt.Sprintf("terraform_%s", t.Name()),
 		fmt.Sprintf("test with spaces: %s", t.Name()),
 	}
-	for _, schemaName := range testCases {
-		t.Run(schemaName, func(t *testing.T) {
+	for _, testCaseName := range testCases {
+		t.Run(testCaseName, func(t *testing.T) {
+			schemaName := testCaseName
+			tableName := testCaseName
+			indexName := testCaseName
 			dbCleaner, err := sql.Open("postgres", connStr)
 			if err != nil {
 				t.Fatal(err)
@@ -398,6 +535,8 @@ func TestBackendStates(t *testing.T) {
 			config := backend.TestWrapConfig(map[string]interface{}{
 				"conn_str":    connStr,
 				"schema_name": schemaName,
+				"table_name":  tableName,
+				"index_name":  indexName,
 			})
 			b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
 
@@ -414,6 +553,8 @@ func TestBackendStateLocks(t *testing.T) {
 	testACC(t)
 	connStr := getDatabaseUrl()
 	schemaName := fmt.Sprintf("terraform_%s", t.Name())
+	tableName := fmt.Sprintf("terraform_%s", t.Name())
+	indexName := fmt.Sprintf("terraform_%s", t.Name())
 	dbCleaner, err := sql.Open("postgres", connStr)
 	if err != nil {
 		t.Fatal(err)
@@ -423,6 +564,8 @@ func TestBackendStateLocks(t *testing.T) {
 	config := backend.TestWrapConfig(map[string]interface{}{
 		"conn_str":    connStr,
 		"schema_name": schemaName,
+		"table_name":  tableName,
+		"index_name":  indexName,
 	})
 	b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
 
@@ -447,10 +590,12 @@ func TestBackendConcurrentLock(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	getStateMgr := func(schemaName string) (statemgr.Full, *statemgr.LockInfo) {
+	getStateMgr := func(schemaName string, tableName string, indexName string) (statemgr.Full, *statemgr.LockInfo) {
 		config := backend.TestWrapConfig(map[string]interface{}{
 			"conn_str":    connStr,
 			"schema_name": schemaName,
+			"table_name":  tableName,
+			"index_name":  indexName,
 		})
 		b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
 
@@ -469,12 +614,19 @@ func TestBackendConcurrentLock(t *testing.T) {
 		return stateMgr, info
 	}
 
-	firstSchema, secondSchema := fmt.Sprintf("terraform_%s_1", t.Name()), fmt.Sprintf("terraform_%s_2", t.Name())
+	firstSchema := fmt.Sprintf("terraform_%s_1", t.Name())
+	firstTable := fmt.Sprintf("terraform_%s_1", t.Name())
+	firstIndex := fmt.Sprintf("terraform_%s_1", t.Name())
+
+	secondSchema := fmt.Sprintf("terraform_%s_2", t.Name())
+	secondTable := fmt.Sprintf("terraform_%s_2", t.Name())
+	secondIndex := fmt.Sprintf("terraform_%s_2", t.Name())
+
 	defer dropSchema(t, dbCleaner, firstSchema)
 	defer dropSchema(t, dbCleaner, secondSchema)
 
-	s1, i1 := getStateMgr(firstSchema)
-	s2, i2 := getStateMgr(secondSchema)
+	s1, i1 := getStateMgr(firstSchema, firstTable, firstIndex)
+	s2, i2 := getStateMgr(secondSchema, secondTable, secondIndex)
 
 	// First we need to create the workspace as the lock for creating them is
 	// global
@@ -487,7 +639,7 @@ func TestBackendConcurrentLock(t *testing.T) {
 		t.Fatalf("failed to persist state: %v", err)
 	}
 
-	if err := s1.Unlock(lockID1); err != nil {
+	if err = s1.Unlock(lockID1); err != nil {
 		t.Fatalf("failed to unlock first state: %v", err)
 	}
 
@@ -500,7 +652,7 @@ func TestBackendConcurrentLock(t *testing.T) {
 		t.Fatalf("failed to persist state: %v", err)
 	}
 
-	if err := s2.Unlock(lockID2); err != nil {
+	if err = s2.Unlock(lockID2); err != nil {
 		t.Fatalf("failed to unlock first state: %v", err)
 	}
 
@@ -515,11 +667,11 @@ func TestBackendConcurrentLock(t *testing.T) {
 		t.Fatalf("failed to lock second state: %v", err)
 	}
 
-	if err := s1.Unlock(lockID1); err != nil {
+	if err = s1.Unlock(lockID1); err != nil {
 		t.Fatalf("failed to unlock first state: %v", err)
 	}
 
-	if err := s2.Unlock(lockID2); err != nil {
+	if err = s2.Unlock(lockID2); err != nil {
 		t.Fatalf("failed to unlock first state: %v", err)
 	}
 }
@@ -529,16 +681,9 @@ func getDatabaseUrl() string {
 }
 
 func dropSchema(t *testing.T, db *sql.DB, schemaName string) {
-	dropSchemaByQuotedName(t, db, pq.QuoteIdentifier(schemaName))
-}
-
-func dropSchemaByQuotedName(t *testing.T, db *sql.DB, quotedSchemaName string) {
-	rows, err := db.Query(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", quotedSchemaName))
+	query := fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pq.QuoteIdentifier(schemaName))
+	_, err := db.Exec(query)
 	if err != nil {
 		t.Fatal(err)
 	}
-	defer rows.Close()
-	if err = rows.Err(); err != nil {
-		t.Fatal(err)
-	}
 }
diff --git a/internal/backend/remote-state/pg/client.go b/internal/backend/remote-state/pg/client.go
index a745aa08ac4..d500e407b5e 100644
--- a/internal/backend/remote-state/pg/client.go
+++ b/internal/backend/remote-state/pg/client.go
@@ -9,6 +9,7 @@ import (
 	"crypto/md5"
 	"database/sql"
 	"fmt"
+	"github.com/lib/pq"
 	"hash/fnv"
 
 	uuid "github.com/hashicorp/go-uuid"
@@ -22,13 +23,15 @@ type RemoteClient struct {
 	Client     *sql.DB
 	Name       string
 	SchemaName string
+	TableName  string
+	IndexName  string
 
 	info *statemgr.LockInfo
 }
 
 func (c *RemoteClient) Get() (*remote.Payload, error) {
-	query := `SELECT data FROM %s.%s WHERE name = $1`
-	row := c.Client.QueryRow(fmt.Sprintf(query, c.SchemaName, statesTableName), c.Name)
+	query := fmt.Sprintf(`SELECT data FROM %s.%s WHERE name = $1`, pq.QuoteIdentifier(c.SchemaName), pq.QuoteIdentifier(c.TableName))
+	row := c.Client.QueryRow(query, c.Name)
 	var data []byte
 	err := row.Scan(&data)
 	switch {
@@ -47,10 +50,10 @@ func (c *RemoteClient) Get() (*remote.Payload, error) {
 }
 
 func (c *RemoteClient) Put(data []byte) error {
-	query := `INSERT INTO %s.%s (name, data) VALUES ($1, $2)
+	query := fmt.Sprintf(`INSERT INTO %s.%s (name, data) VALUES ($1, $2)
 		ON CONFLICT (name) DO UPDATE
-		SET data = $2 WHERE %s.name = $1`
-	_, err := c.Client.Exec(fmt.Sprintf(query, c.SchemaName, statesTableName, statesTableName), c.Name, data)
+		SET data = $2 WHERE %s.name = $1`, pq.QuoteIdentifier(c.SchemaName), pq.QuoteIdentifier(c.TableName), pq.QuoteIdentifier(c.TableName))
+	_, err := c.Client.Exec(query, c.Name, data)
 	if err != nil {
 		return err
 	}
@@ -58,8 +61,8 @@ func (c *RemoteClient) Put(data []byte) error {
 }
 
 func (c *RemoteClient) Delete() error {
-	query := `DELETE FROM %s.%s WHERE name = $1`
-	_, err := c.Client.Exec(fmt.Sprintf(query, c.SchemaName, statesTableName), c.Name)
+	query := fmt.Sprintf(`DELETE FROM %s.%s WHERE name = $1`, pq.QuoteIdentifier(c.SchemaName), pq.QuoteIdentifier(c.TableName))
+	_, err := c.Client.Exec(query, c.Name)
 	if err != nil {
 		return err
 	}
@@ -81,8 +84,8 @@ func (c *RemoteClient) Lock(info *statemgr.LockInfo) (string, error) {
 	// Local helper function so we can call it multiple places
 	//
 	lockUnlock := func(pgLockId string) error {
-		query := `SELECT pg_advisory_unlock(%s)`
-		row := c.Client.QueryRow(fmt.Sprintf(query, pgLockId))
+		query := `SELECT pg_advisory_unlock($1)`
+		row := c.Client.QueryRow(query, pgLockId)
 		var didUnlock []byte
 		err := row.Scan(&didUnlock)
 		if err != nil {
@@ -94,16 +97,17 @@ func (c *RemoteClient) Lock(info *statemgr.LockInfo) (string, error) {
 	creationLockID := c.composeCreationLockID()
 
 	// Try to acquire locks for the existing row `id` and the creation lock.
-	query := fmt.Sprintf(`SELECT %s.id, pg_try_advisory_lock(%s.id), pg_try_advisory_lock(%s) FROM %s.%s WHERE %s.name = $1`,
-		statesTableName, statesTableName, creationLockID, c.SchemaName, statesTableName, statesTableName)
+	query := fmt.Sprintf(`SELECT %s.id, pg_try_advisory_lock(%s.id), pg_try_advisory_lock($1) FROM %s.%s WHERE %s.name = $2`,
+		pq.QuoteIdentifier(c.TableName), pq.QuoteIdentifier(c.TableName), pq.QuoteIdentifier(c.SchemaName), pq.QuoteIdentifier(c.TableName), pq.QuoteIdentifier(c.TableName))
 
-	row := c.Client.QueryRow(query, c.Name)
+	row := c.Client.QueryRow(query, creationLockID, c.Name)
 	var pgLockId, didLock, didLockForCreate []byte
 	err = row.Scan(&pgLockId, &didLock, &didLockForCreate)
 	switch {
 	case err == sql.ErrNoRows:
 		// No rows means we're creating the workspace. Take the creation lock.
-		innerRow := c.Client.QueryRow(fmt.Sprintf(`SELECT pg_try_advisory_lock(%s)`, creationLockID))
+		query = `SELECT pg_try_advisory_lock($1)`
+		innerRow := c.Client.QueryRow(query, creationLockID)
 		var innerDidLock []byte
 		err := innerRow.Scan(&innerDidLock)
 		if err != nil {
@@ -135,8 +139,8 @@ func (c *RemoteClient) Lock(info *statemgr.LockInfo) (string, error) {
 
 func (c *RemoteClient) Unlock(id string) error {
 	if c.info != nil && c.info.Path != "" {
-		query := `SELECT pg_advisory_unlock(%s)`
-		row := c.Client.QueryRow(fmt.Sprintf(query, c.info.Path))
+		query := `SELECT pg_advisory_unlock($1)`
+		row := c.Client.QueryRow(query, c.info.Path)
 		var didUnlock []byte
 		err := row.Scan(&didUnlock)
 		if err != nil {
@@ -149,6 +153,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "\x00" + c.TableName))
 	return fmt.Sprintf("%d", int64(hash.Sum32())*-1)
 }
diff --git a/internal/backend/remote-state/pg/client_test.go b/internal/backend/remote-state/pg/client_test.go
index 302438e5d62..b3c7c406a0a 100644
--- a/internal/backend/remote-state/pg/client_test.go
+++ b/internal/backend/remote-state/pg/client_test.go
@@ -29,6 +29,8 @@ func TestRemoteClient(t *testing.T) {
 	testACC(t)
 	connStr := getDatabaseUrl()
 	schemaName := fmt.Sprintf("terraform_%s", t.Name())
+	tableName := fmt.Sprintf("terraform_%s", t.Name())
+	indexName := fmt.Sprintf("terraform_%s", t.Name())
 	dbCleaner, err := sql.Open("postgres", connStr)
 	if err != nil {
 		t.Fatal(err)
@@ -38,6 +40,8 @@ func TestRemoteClient(t *testing.T) {
 	config := backend.TestWrapConfig(map[string]interface{}{
 		"conn_str":    connStr,
 		"schema_name": schemaName,
+		"table_name":  tableName,
+		"index_name":  indexName,
 	})
 	b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
 
@@ -57,6 +61,8 @@ func TestRemoteLocks(t *testing.T) {
 	testACC(t)
 	connStr := getDatabaseUrl()
 	schemaName := fmt.Sprintf("terraform_%s", t.Name())
+	tableName := fmt.Sprintf("terraform_%s", t.Name())
+	indexName := fmt.Sprintf("terraform_%s", t.Name())
 	dbCleaner, err := sql.Open("postgres", connStr)
 	if err != nil {
 		t.Fatal(err)
@@ -66,6 +72,8 @@ func TestRemoteLocks(t *testing.T) {
 	config := backend.TestWrapConfig(map[string]interface{}{
 		"conn_str":    connStr,
 		"schema_name": schemaName,
+		"table_name":  tableName,
+		"index_name":  indexName,
 	})
 
 	b1 := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
@@ -94,7 +102,12 @@ func TestConcurrentCreationLocksInDifferentSchemas(t *testing.T) {
 	}
 
 	firstSchema := fmt.Sprintf("terraform_%s_1", t.Name())
+	firstTable := fmt.Sprintf("terraform_%s_1", t.Name())
+	firstIndex := fmt.Sprintf("terraform_%s_1", t.Name())
+
 	secondSchema := fmt.Sprintf("terraform_%s_2", t.Name())
+	secondTable := fmt.Sprintf("terraform_%s_2", t.Name())
+	secondIndex := fmt.Sprintf("terraform_%s_2", t.Name())
 
 	defer dropSchema(t, dbCleaner, firstSchema)
 	defer dropSchema(t, dbCleaner, secondSchema)
@@ -102,11 +115,117 @@ func TestConcurrentCreationLocksInDifferentSchemas(t *testing.T) {
 	firstConfig := backend.TestWrapConfig(map[string]interface{}{
 		"conn_str":    connStr,
 		"schema_name": firstSchema,
+		"table_name":  firstTable,
+		"index_name":  firstIndex,
 	})
 
 	secondConfig := backend.TestWrapConfig(map[string]interface{}{
 		"conn_str":    connStr,
 		"schema_name": secondSchema,
+		"table_name":  secondTable,
+		"index_name":  secondIndex,
+	})
+
+	//nolint:errcheck // this is a test, I am fine with panic here
+	firstBackend := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), firstConfig).(*Backend)
+
+	//nolint:errcheck // this is a test, I am fine with panic here
+	secondBackend := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), secondConfig).(*Backend)
+
+	//nolint:errcheck // this is a test, I am fine with panic here
+	thirdBackend := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), secondConfig).(*Backend)
+
+	// We operate on remote clients instead of state managers to simulate the
+	// first call to backend.StateMgr(), which creates an empty state in default
+	// workspace.
+	firstClient := &RemoteClient{
+		Client:     firstBackend.db,
+		Name:       backend.DefaultStateName,
+		SchemaName: firstBackend.schemaName,
+		TableName:  firstBackend.tableName,
+		IndexName:  firstBackend.indexName,
+	}
+
+	secondClient := &RemoteClient{
+		Client:     secondBackend.db,
+		Name:       backend.DefaultStateName,
+		SchemaName: secondBackend.schemaName,
+		TableName:  secondBackend.tableName,
+		IndexName:  secondBackend.indexName,
+	}
+
+	thirdClient := &RemoteClient{
+		Client:     thirdBackend.db,
+		Name:       backend.DefaultStateName,
+		SchemaName: thirdBackend.schemaName,
+		TableName:  thirdBackend.tableName,
+		IndexName:  thirdBackend.indexName,
+	}
+
+	// It doesn't matter what lock info to supply for workspace creation.
+	lock := &statemgr.LockInfo{
+		ID:        "1",
+		Operation: "test",
+		Info:      "This needs to lock for workspace creation",
+		Who:       "me",
+		Version:   "1",
+		Created:   time.Date(1999, 8, 19, 0, 0, 0, 0, time.UTC),
+	}
+
+	// Those calls with empty database must think they are locking
+	// for workspace creation, both of them must succeed since they
+	// are operating on different schemas.
+	if _, err = firstClient.Lock(lock); err != nil {
+		t.Fatal(err)
+	}
+	if _, err = secondClient.Lock(lock); err != nil {
+		t.Fatal(err)
+	}
+
+	// This call must fail since we are trying to acquire the same
+	// lock as the first client. We need to make this call from a
+	// separate session, since advisory locks are okay to be re-acquired
+	// during the same session.
+	if _, err = thirdClient.Lock(lock); err == nil {
+		t.Fatal("Expected an error to be thrown on a second lock attempt")
+	} else if lockErr := err.(*statemgr.LockError); lockErr.Info != lock && //nolint:errcheck,errorlint // this is a test, I am fine with panic here
+		lockErr.Err.Error() != "Already locked for workspace creation: default" {
+		t.Fatalf("Unexpected error thrown on a second lock attempt: %v", err)
+	}
+}
+
+// TestConcurrentCreationLocksInDifferentTables tests whether backends with different tables
+// affect each other while taking global workspace creation locks.
+func TestConcurrentCreationLocksInDifferentTables(t *testing.T) {
+	testACC(t)
+	connStr := getDatabaseUrl()
+	dbCleaner, err := sql.Open("postgres", connStr)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	schema := fmt.Sprintf("terraform_%s", t.Name())
+
+	firstTable := fmt.Sprintf("terraform_%s_1", t.Name())
+	firstIndex := fmt.Sprintf("terraform_%s_1", t.Name())
+
+	secondTable := fmt.Sprintf("terraform_%s_2", t.Name())
+	secondIndex := fmt.Sprintf("terraform_%s_2", t.Name())
+
+	defer dropSchema(t, dbCleaner, schema)
+
+	firstConfig := backend.TestWrapConfig(map[string]interface{}{
+		"conn_str":    connStr,
+		"schema_name": schema,
+		"table_name":  firstTable,
+		"index_name":  firstIndex,
+	})
+
+	secondConfig := backend.TestWrapConfig(map[string]interface{}{
+		"conn_str":    connStr,
+		"schema_name": schema,
+		"table_name":  secondTable,
+		"index_name":  secondIndex,
 	})
 
 	//nolint:errcheck // this is a test, I am fine with panic here
@@ -125,18 +244,24 @@ func TestConcurrentCreationLocksInDifferentSchemas(t *testing.T) {
 		Client:     firstBackend.db,
 		Name:       backend.DefaultStateName,
 		SchemaName: firstBackend.schemaName,
+		TableName:  firstBackend.tableName,
+		IndexName:  firstBackend.indexName,
 	}
 
 	secondClient := &RemoteClient{
 		Client:     secondBackend.db,
 		Name:       backend.DefaultStateName,
 		SchemaName: secondBackend.schemaName,
+		TableName:  secondBackend.tableName,
+		IndexName:  secondBackend.indexName,
 	}
 
 	thirdClient := &RemoteClient{
 		Client:     thirdBackend.db,
 		Name:       backend.DefaultStateName,
 		SchemaName: thirdBackend.schemaName,
+		TableName:  thirdBackend.tableName,
+		IndexName:  thirdBackend.indexName,
 	}
 
 	// It doesn't matter what lock info to supply for workspace creation.
diff --git a/website/docs/language/settings/backends/pg.mdx b/website/docs/language/settings/backends/pg.mdx
index 60977036503..7b3b706c0aa 100644
--- a/website/docs/language/settings/backends/pg.mdx
+++ b/website/docs/language/settings/backends/pg.mdx
@@ -97,12 +97,16 @@ The following configuration options or environment variables are supported:
 - `conn_str` - Postgres connection string; a `postgres://` URL. The `PG_CONN_STR` and [standard `libpq`](https://www.postgresql.org/docs/current/libpq-envars.html) environment variables can also be used to indicate how to connect to the PostgreSQL database.
 - `schema_name` - Name of the automatically-managed Postgres schema, default to `terraform_remote_state`. Can also be set using the `PG_SCHEMA_NAME` environment variable.
 - `skip_schema_creation` - If set to `true`, the Postgres schema must already exist. Can also be set using the `PG_SKIP_SCHEMA_CREATION` environment variable. OpenTofu won't try to create the schema, this is useful when it has already been created by a database administrator.
+- `table_name` - Name of the automatically-managed Postgres table, default to `states`. Can also be set using the `PG_TABLE_NAME` environment variable.
 - `skip_table_creation` - If set to `true`, the Postgres table must already exist. Can also be set using the `PG_SKIP_TABLE_CREATION` environment variable. OpenTofu won't try to create the table, this is useful when it has already been created by a database administrator.
+- `index_name` - Name of the automatically-managed Postgres index, default to `states_by_name`. Can also be set using the `PG_INDEX_NAME` environment variable.
 - `skip_index_creation` - If set to `true`, the Postgres index must already exist. Can also be set using the `PG_SKIP_INDEX_CREATION` environment variable. OpenTofu won't try to create the index, this is useful when it has already been created by a database administrator.
 
+Please, keep in mind, that if `table_name` or `schema_name` is changed, you would need to manually migrate the existing state data.
+
 ## Technical Design
 
-This backend creates one table **states** in the automatically-managed Postgres schema configured by the `schema_name` variable.
+This backend creates a table with a name defined by `table_name` in the automatically-managed Postgres schema configured by the `schema_name` variable.
 
 The table is keyed by the [workspace](../../../language/state/workspaces.mdx) name. If workspaces are not in use, the name `default` is used.
 
@@ -110,7 +114,7 @@ Locking is supported using [Postgres advisory locks](https://www.postgresql.org/
 
 Advisory locks are used for multiple scenarios: state updates and state creation. When the state is updated, advisory lock is acquired with state ID. Otherwise, on state (and workspace) creation, it is acquired with the hash of schema name. This way, multiple backend configurations doesn't affect each other, when the database is shared.
 
-The **states** table contains:
+The table used for state contains:
 
 - a serial integer `id`, used as the key for advisory locks
 - the workspace `name` key as _text_ with a unique index


Reviews:
body: Thank you for this @taliesins ! Normally, we like to discuss features in issue before accepting a PR, but this seems fairly straight forward. I'm queueing this for the core team to discuss, but otherwise LGTM!
state: DISMISSED
author_association: NONE
submitted_at: 2025-02-01T19:58:23Z
user: login: ollevche
body: Looks good overall, thank you for the contribution! I left a comment regarding the hashing of schema + table combination.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T11:15:30Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T15:04:36Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T15:06:30Z
user: login: ollevche
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T17:13:11Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-04T11:58:12Z
user: login: ollevche
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-05T13:25:45Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-06T21:06:05Z
user: login: ollevche
body: Hey, @taliesins! Sorry for delay from our side on this one.

The PR looks great, I left a few questions and suggestions. Could you please also fix a failed linting check?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T10:56:24Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T09:39:07Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T09:40:54Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T10:10:31Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T09:17:02Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T09:23:06Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:33:11Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T16:34:09Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T12:20:10Z
user: login: taliesins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T19:28:44Z
user: login: cam72cam
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T21:20:54Z

Review comments:
diff_hunk: @@ -150,6 +152,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "|" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: ollevche
body: I am wondering if we introduce any new locking quirks related to multiple backends using the same database and schema, but different tables. There is no issue that I can think of right now, but that's an interesting use-case we now allow.

Another thing we need to keep in mind - a separator symbol for hash must be something people can't use as a part of schema name or table name. Currently, we can have a "fake" collision, which may interrupt the flow, for example combination of "schema|" and "table" will result in the same hash as "schema" and "|table" pair. I don't think this is a critical issue, however still an issue. 
created_at: 2025-02-03T10:50:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +152,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "|" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: taliesins
body: I selected the pipe "|" character as it can't be used in schema or table names in Postgres. This means we will never land up with the scenario outlined.

[Allowed characters in identifiers](https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS)
created_at: 2025-02-03T15:04:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +152,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "|" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: taliesins
body: The only quirk I could think of is collision of index names across tables. So it will require the user to configure the table and the index appropriately.


created_at: 2025-02-03T15:06:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +152,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "|" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: ollevche
body: `pg` backend uses quoted identifiers, so it could contain any character, including the pipe. From the docs you linked to:

> Quoted identifiers can contain any character, except the character with code zero. (To include a double quote, write two double quotes.) This allows constructing table or column names that would otherwise not be possible, such as ones containing spaces or ampersands. The length limitation still applies.
created_at: 2025-02-03T17:13:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +152,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "|" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: taliesins
body: @ollevche you are correct. Can you suggest another string to use as a separator or are we willing to accept the chance of a collision being highly unlikely? 
created_at: 2025-02-04T11:58:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +152,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "|" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: ollevche
body: I think we can use some weird sequence (e.g. "|<.") to lower the potential of collisions, but the pipe is already something unpopular I believe. 
created_at: 2025-02-05T13:25:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +152,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "|" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: taliesins
body: I changed the seperator to use "\x00" which is the character with code zero. This way there can never be a conflict.
created_at: 2025-02-06T21:06:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -96,7 +107,42 @@ func (b *Backend) configure(ctx context.Context) error {
 	data := b.configData
 
 	b.connStr = data.Get("conn_str").(string)
-	b.schemaName = pq.QuoteIdentifier(data.Get("schema_name").(string))
+
+	schemaName, ok := data.Get("schema_name").(string)
path: internal/backend/remote-state/pg/backend.go
user: login: ollevche
body: Why did we move quoting from here to every other SQL query?
created_at: 2025-02-26T10:23:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -96,7 +107,42 @@ func (b *Backend) configure(ctx context.Context) error {
 	data := b.configData
 
 	b.connStr = data.Get("conn_str").(string)
-	b.schemaName = pq.QuoteIdentifier(data.Get("schema_name").(string))
+
+	schemaName, ok := data.Get("schema_name").(string)
+	if !ok {
path: internal/backend/remote-state/pg/backend.go
user: login: ollevche
body: I believe it should be already set at this time. The priority list for getting that kind of values is the following:

* configuration
* env variable
* static default value

I think it is safe to leave this code as is (without type conversion checks).
created_at: 2025-02-26T10:30:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -205,9 +248,48 @@ func TestBackendConfig(t *testing.T) {
 				t.Fatal("Backend could not be configured")
 			}
 
-			_, err = b.db.Query(fmt.Sprintf("SELECT name, data FROM %s.%s LIMIT 1", schemaName, statesTableName))
-			if err != nil {
-				t.Fatal(err)
+			// Make sure everything has been created
+			if !skipSchemaCreation {
+				// Make sure schema exists
+				var count int
+				query := fmt.Sprintf(`select count(*) from information_schema.schemata where schema_name=$1`)
path: internal/backend/remote-state/pg/backend_test.go
user: login: ollevche
body: from the linter: fmt.Sprintf is unnecessary here and a few other places down below
created_at: 2025-02-26T10:32:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +154,6 @@ func (c *RemoteClient) Unlock(id string) error {
 
 func (c *RemoteClient) composeCreationLockID() string {
 	hash := fnv.New32()
-	hash.Write([]byte(c.SchemaName))
+	hash.Write([]byte(c.SchemaName + "\x00" + c.TableName))
path: internal/backend/remote-state/pg/client.go
user: login: ollevche
body: nice! it's a good idea to pick that char
created_at: 2025-02-26T10:36:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,7 +97,9 @@ The following configuration options or environment variables are supported:
 - `conn_str` - Postgres connection string; a `postgres://` URL. The `PG_CONN_STR` and [standard `libpq`](https://www.postgresql.org/docs/current/libpq-envars.html) environment variables can also be used to indicate how to connect to the PostgreSQL database.
 - `schema_name` - Name of the automatically-managed Postgres schema, default to `terraform_remote_state`. Can also be set using the `PG_SCHEMA_NAME` environment variable.
 - `skip_schema_creation` - If set to `true`, the Postgres schema must already exist. Can also be set using the `PG_SKIP_SCHEMA_CREATION` environment variable. OpenTofu won't try to create the schema, this is useful when it has already been created by a database administrator.
+- `table_name` - Name of the automatically-managed Postgres table, default to `states`. Can also be set using the `PG_TABLE_NAME` environment variable.
 - `skip_table_creation` - If set to `true`, the Postgres table must already exist. Can also be set using the `PG_SKIP_TABLE_CREATION` environment variable. OpenTofu won't try to create the table, this is useful when it has already been created by a database administrator.
+- `index_name` - Name of the automatically-managed Postgres index, default to `states_by_name`. Can also be set using the `PG_INDEX_NAME` environment variable.
 - `skip_index_creation` - If set to `true`, the Postgres index must already exist. Can also be set using the `PG_SKIP_INDEX_CREATION` environment variable. OpenTofu won't try to create the index, this is useful when it has already been created by a database administrator.
 
 ## Technical Design
path: website/docs/language/settings/backends/pg.mdx
user: login: ollevche
body: Let's put a warning before the technical design section to remind users OpenTofu is not going to do any migrations:

> Please, keep in mind, that if the data-related configuration changes (such as `table_name` or `schema_name`), you would need to manually migrate the existing data.
created_at: 2025-02-26T10:41:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,6 +141,8 @@ func TestBackendConfig(t *testing.T) {
 			Config: map[string]interface{}{
 				"conn_str":    connStr,
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
path: internal/backend/remote-state/pg/backend_test.go
user: login: ollevche
body: Let's leave a few tests, where we don't set `table_name` and `index_name`
created_at: 2025-02-26T10:50:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -96,7 +107,42 @@ func (b *Backend) configure(ctx context.Context) error {
 	data := b.configData
 
 	b.connStr = data.Get("conn_str").(string)
-	b.schemaName = pq.QuoteIdentifier(data.Get("schema_name").(string))
+
+	schemaName, ok := data.Get("schema_name").(string)
path: internal/backend/remote-state/pg/backend.go
user: login: taliesins
body: Keeping track of variables that may or may not be quoted become hard to track and in some instances I needed the unquoted value. Originally I started a parallel set of variables, but it looked odd to pass in the same variable with a quoted and non quoted version. 

Moving it closer to sql query made it much easier to understand. The only drawback being we might have to quote the same variable more then once.
created_at: 2025-03-19T09:39:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,7 +97,9 @@ The following configuration options or environment variables are supported:
 - `conn_str` - Postgres connection string; a `postgres://` URL. The `PG_CONN_STR` and [standard `libpq`](https://www.postgresql.org/docs/current/libpq-envars.html) environment variables can also be used to indicate how to connect to the PostgreSQL database.
 - `schema_name` - Name of the automatically-managed Postgres schema, default to `terraform_remote_state`. Can also be set using the `PG_SCHEMA_NAME` environment variable.
 - `skip_schema_creation` - If set to `true`, the Postgres schema must already exist. Can also be set using the `PG_SKIP_SCHEMA_CREATION` environment variable. OpenTofu won't try to create the schema, this is useful when it has already been created by a database administrator.
+- `table_name` - Name of the automatically-managed Postgres table, default to `states`. Can also be set using the `PG_TABLE_NAME` environment variable.
 - `skip_table_creation` - If set to `true`, the Postgres table must already exist. Can also be set using the `PG_SKIP_TABLE_CREATION` environment variable. OpenTofu won't try to create the table, this is useful when it has already been created by a database administrator.
+- `index_name` - Name of the automatically-managed Postgres index, default to `states_by_name`. Can also be set using the `PG_INDEX_NAME` environment variable.
 - `skip_index_creation` - If set to `true`, the Postgres index must already exist. Can also be set using the `PG_SKIP_INDEX_CREATION` environment variable. OpenTofu won't try to create the index, this is useful when it has already been created by a database administrator.
 
 ## Technical Design
path: website/docs/language/settings/backends/pg.mdx
user: login: taliesins
body: If the value is not applied it will fall back to the previous hard coded version, which means it should be 100% backward compatible. Am I missing something?
created_at: 2025-03-19T09:40:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -205,9 +248,48 @@ func TestBackendConfig(t *testing.T) {
 				t.Fatal("Backend could not be configured")
 			}
 
-			_, err = b.db.Query(fmt.Sprintf("SELECT name, data FROM %s.%s LIMIT 1", schemaName, statesTableName))
-			if err != nil {
-				t.Fatal(err)
+			// Make sure everything has been created
+			if !skipSchemaCreation {
+				// Make sure schema exists
+				var count int
+				query := fmt.Sprintf(`select count(*) from information_schema.schemata where schema_name=$1`)
path: internal/backend/remote-state/pg/backend_test.go
user: login: taliesins
body: Updated. It should pass golangci-lint now.
created_at: 2025-03-19T10:10:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -96,7 +107,42 @@ func (b *Backend) configure(ctx context.Context) error {
 	data := b.configData
 
 	b.connStr = data.Get("conn_str").(string)
-	b.schemaName = pq.QuoteIdentifier(data.Get("schema_name").(string))
+
+	schemaName, ok := data.Get("schema_name").(string)
+	if !ok {
path: internal/backend/remote-state/pg/backend.go
user: login: taliesins
body: Fixed
created_at: 2025-03-20T09:17:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,7 +97,9 @@ The following configuration options or environment variables are supported:
 - `conn_str` - Postgres connection string; a `postgres://` URL. The `PG_CONN_STR` and [standard `libpq`](https://www.postgresql.org/docs/current/libpq-envars.html) environment variables can also be used to indicate how to connect to the PostgreSQL database.
 - `schema_name` - Name of the automatically-managed Postgres schema, default to `terraform_remote_state`. Can also be set using the `PG_SCHEMA_NAME` environment variable.
 - `skip_schema_creation` - If set to `true`, the Postgres schema must already exist. Can also be set using the `PG_SKIP_SCHEMA_CREATION` environment variable. OpenTofu won't try to create the schema, this is useful when it has already been created by a database administrator.
+- `table_name` - Name of the automatically-managed Postgres table, default to `states`. Can also be set using the `PG_TABLE_NAME` environment variable.
 - `skip_table_creation` - If set to `true`, the Postgres table must already exist. Can also be set using the `PG_SKIP_TABLE_CREATION` environment variable. OpenTofu won't try to create the table, this is useful when it has already been created by a database administrator.
+- `index_name` - Name of the automatically-managed Postgres index, default to `states_by_name`. Can also be set using the `PG_INDEX_NAME` environment variable.
 - `skip_index_creation` - If set to `true`, the Postgres index must already exist. Can also be set using the `PG_SKIP_INDEX_CREATION` environment variable. OpenTofu won't try to create the index, this is useful when it has already been created by a database administrator.
 
 ## Technical Design
path: website/docs/language/settings/backends/pg.mdx
user: login: taliesins
body: I have updated the documentation to include a warning about migrating state
created_at: 2025-03-20T09:23:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,6 +141,8 @@ func TestBackendConfig(t *testing.T) {
 			Config: map[string]interface{}{
 				"conn_str":    connStr,
 				"schema_name": fmt.Sprintf("terraform_%s", t.Name()),
+				"table_name":  fmt.Sprintf("terraform_%s", t.Name()),
+				"index_name":  fmt.Sprintf("terraform_%s", t.Name()),
path: internal/backend/remote-state/pg/backend_test.go
user: login: taliesins
body: Done! This required me to rework the way test gets config values.

I have added extra test cases:
- valid-config (nothing set)
- valid-config-with-table-name
- valid-config-with-index-name
- valid-config-with-table-name-and-index-name
created_at: 2025-03-21T16:33:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -96,7 +107,42 @@ func (b *Backend) configure(ctx context.Context) error {
 	data := b.configData
 
 	b.connStr = data.Get("conn_str").(string)
-	b.schemaName = pq.QuoteIdentifier(data.Get("schema_name").(string))
+
+	schemaName, ok := data.Get("schema_name").(string)
path: internal/backend/remote-state/pg/backend.go
user: login: taliesins
body: I am going to close this for now. Feel free to re-open.
created_at: 2025-03-21T16:34:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -307,47 +410,77 @@ func TestBackendConfigSkipOptions(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.Name, func(t *testing.T) {
 			schemaName := tc.Name
+			tableName := tc.Name
+			indexName := tc.Name
 
 			config := backend.TestWrapConfig(map[string]interface{}{
 				"conn_str":             connStr,
 				"schema_name":          schemaName,
+				"table_name":           tableName,
+				"index_name":           indexName,
 				"skip_schema_creation": tc.SkipSchemaCreation,
 				"skip_table_creation":  tc.SkipTableCreation,
 				"skip_index_creation":  tc.SkipIndexCreation,
 			})
-			schemaName = pq.QuoteIdentifier(schemaName)
+
 			db, err := sql.Open("postgres", connStr)
 			if err != nil {
 				t.Fatal(err)
 			}
 
 			if tc.Setup != nil {
-				tc.Setup(t, db, schemaName)
+				tc.Setup(t, db, schemaName, tableName, indexName)
 			}
-			defer dropSchemaByQuotedName(t, db, schemaName)
+			defer dropSchema(t, db, schemaName)
 
-			b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
+			unconfiguredBackend := New(encryption.StateEncryptionDisabled())
+			b := backend.TestBackendConfig(t, unconfiguredBackend, config).(*Backend)
 
 			if b == nil {
 				t.Fatal("Backend could not be configured")
 			}
 
 			// Make sure everything has been created
+			if tc.TestSchemaIsPresent {
+				// Make sure schema exists
+				var count int
+				query := `select count(*) from information_schema.schemata where schema_name=$1`
+				if err = b.db.QueryRow(query, schemaName).Scan(&count); err != nil {
+					t.Fatal(err)
+				}
 
-			// This tests that both the schema and the table have been created
-			_, err = b.db.Query(fmt.Sprintf("SELECT name, data FROM %s.%s LIMIT 1", schemaName, statesTableName))
-			if err != nil {
-				t.Fatal(err)
+				if count != 1 {
+					t.Fatalf("The schema has not been created (%d)", count)
+				}
 			}
+
+			if tc.TestTableIsPresent {
+				// Make sure that the index exists
+				var count int
+
+				query := `select count(*) from pg_catalog.pg_tables where schemaname=$1 and tablename=$2;`
+				err = b.db.QueryRow(query, schemaName, tableName).Scan(&count)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				if count != 1 {
+					t.Fatalf("The table has not been created (%d)", count)
+				}
+			}
+
 			if tc.TestIndexIsPresent {
 				// Make sure that the index exists
-				query := `select count(*) from pg_indexes where schemaname=$1 and tablename=$2 and indexname=$3;`
 				var count int
-				if err := b.db.QueryRow(query, tc.Name, statesTableName, statesIndexName).Scan(&count); err != nil {
+
+				query := `select count(*) from pg_indexes where schemaname=$1 and tablename=$2 and indexname=$3;`
+				err = b.db.QueryRow(query, schemaName, tableName, indexName+"_name_key").Scan(&count)
+				if err != nil {
 					t.Fatal(err)
 				}
+
 				if count != 1 {
-					t.Fatalf("The index has not been created (%d)", count)
+					t.Fatalf("The index has not been c reated (%d)", count)
path: internal/backend/remote-state/pg/backend_test.go
user: login: cam72cam
body: small typo `created`
created_at: 2025-04-09T12:20:10Z
author_association: MEMBER
diff_hunk: @@ -307,47 +410,77 @@ func TestBackendConfigSkipOptions(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.Name, func(t *testing.T) {
 			schemaName := tc.Name
+			tableName := tc.Name
+			indexName := tc.Name
 
 			config := backend.TestWrapConfig(map[string]interface{}{
 				"conn_str":             connStr,
 				"schema_name":          schemaName,
+				"table_name":           tableName,
+				"index_name":           indexName,
 				"skip_schema_creation": tc.SkipSchemaCreation,
 				"skip_table_creation":  tc.SkipTableCreation,
 				"skip_index_creation":  tc.SkipIndexCreation,
 			})
-			schemaName = pq.QuoteIdentifier(schemaName)
+
 			db, err := sql.Open("postgres", connStr)
 			if err != nil {
 				t.Fatal(err)
 			}
 
 			if tc.Setup != nil {
-				tc.Setup(t, db, schemaName)
+				tc.Setup(t, db, schemaName, tableName, indexName)
 			}
-			defer dropSchemaByQuotedName(t, db, schemaName)
+			defer dropSchema(t, db, schemaName)
 
-			b := backend.TestBackendConfig(t, New(encryption.StateEncryptionDisabled()), config).(*Backend)
+			unconfiguredBackend := New(encryption.StateEncryptionDisabled())
+			b := backend.TestBackendConfig(t, unconfiguredBackend, config).(*Backend)
 
 			if b == nil {
 				t.Fatal("Backend could not be configured")
 			}
 
 			// Make sure everything has been created
+			if tc.TestSchemaIsPresent {
+				// Make sure schema exists
+				var count int
+				query := `select count(*) from information_schema.schemata where schema_name=$1`
+				if err = b.db.QueryRow(query, schemaName).Scan(&count); err != nil {
+					t.Fatal(err)
+				}
 
-			// This tests that both the schema and the table have been created
-			_, err = b.db.Query(fmt.Sprintf("SELECT name, data FROM %s.%s LIMIT 1", schemaName, statesTableName))
-			if err != nil {
-				t.Fatal(err)
+				if count != 1 {
+					t.Fatalf("The schema has not been created (%d)", count)
+				}
 			}
+
+			if tc.TestTableIsPresent {
+				// Make sure that the index exists
+				var count int
+
+				query := `select count(*) from pg_catalog.pg_tables where schemaname=$1 and tablename=$2;`
+				err = b.db.QueryRow(query, schemaName, tableName).Scan(&count)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				if count != 1 {
+					t.Fatalf("The table has not been created (%d)", count)
+				}
+			}
+
 			if tc.TestIndexIsPresent {
 				// Make sure that the index exists
-				query := `select count(*) from pg_indexes where schemaname=$1 and tablename=$2 and indexname=$3;`
 				var count int
-				if err := b.db.QueryRow(query, tc.Name, statesTableName, statesIndexName).Scan(&count); err != nil {
+
+				query := `select count(*) from pg_indexes where schemaname=$1 and tablename=$2 and indexname=$3;`
+				err = b.db.QueryRow(query, schemaName, tableName, indexName+"_name_key").Scan(&count)
+				if err != nil {
 					t.Fatal(err)
 				}
+
 				if count != 1 {
-					t.Fatalf("The index has not been created (%d)", count)
+					t.Fatalf("The index has not been c reated (%d)", count)
path: internal/backend/remote-state/pg/backend_test.go
user: login: taliesins
body: Done! I also fixed a typo in the change log.
created_at: 2025-04-09T19:28:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-02-01T00:30:21Z
body: Reminder for the PR assignee: If this is a user-visible change, please update the changelog as part of the PR.
author_association: NONE
user: login: taliesins
created_at: 2025-02-01T00:33:06Z
body: This solves #2162
author_association: CONTRIBUTOR
user: login: ollevche
created_at: 2025-02-03T11:19:30Z
body: Also, `pg` tests are not being run automatically so I checked them locally and they are failing. Here is part of the output I receive:
```
client_test.go:130: Unsupported attribute: An attribute named "stable_name" is not expected here.
FAIL: TestConcurrentCreationLocksInDifferentSchemas (0.00s)
...
client_test.go:226: Unsupported attribute: An attribute named "stable_name" is not expected here.
FAIL: TestConcurrentCreationLocksInDifferentTables (0.00s)
```
author_association: CONTRIBUTOR
user: login: ollevche
created_at: 2025-02-03T17:20:43Z
body: After thinking about the proposal for a while, I believe we are introducing a less consistent UX here. 

Previously, it was possible to set only a schema, locking would work just fine in that case. You can't break it from the user's perspective, even when the underlying database is shared.

Currently, if you set a different index and reuse the underlying database, locking will have lots of per-state collisions, since it uses database-level advisory locking with potentially multiple sets of state IDs (from different indexes).

Users **must** keep a single index per database, otherwise it will not work as intended.
author_association: CONTRIBUTOR
user: login: taliesins
created_at: 2025-02-04T11:51:24Z
body: @ollevche I have fixed the tests. Please could you pull and try to run them locally. Most of the awkwardness in fixing the tests was around literal vs identifier values, so some minor changes to not pass them around and apply escaping at the point of usage.

Perhaps we could think about running PG tests as part of the standard build somehow?
author_association: CONTRIBUTOR
user: login: taliesins
created_at: 2025-02-04T12:35:24Z
body: > After thinking about the proposal for a while, I believe we are introducing a less consistent UX here.

We will only run into this if people explicitly configure the new configuration fields. They default to what was before. So this challenge would only present itself to people looking to explicitly do this.

> Currently, if you set a different index and reuse the underlying database, locking will have lots of per-state collisions, since it uses database-level advisory locking with potentially multiple sets of state IDs (from different indexes).

Are we talking about the usage of `pg_try_advisory_lock()`?

If we are, don't advisory locks exist per database and not a schema level? This means that multiple schemas vs multiple tables will make no difference as multiple schema's is technically multiple databases? So the number of collisions will be the same as before?

Then if we look at the values that are being used to lock on, changing the value we lock on doesn't result in extra lock collisions if choose to store state in 2 different places? 
- current main is `schemaA` vs `schemaB` collisions
- this pr is `schemaA|tableA` vs `schemaA|tableB` collisions

> Users must keep a single index per database, otherwise it will not work as intended.

Are you thinking about the usage of `nextval('public.global_states_id_seq')` to generate ids? If we are using a static namespaces to generate ids across schemas then we make the problem no worse?

From a clients perspective we generate an id for them using `uuid.GenerateUUID()` and generate the lock name idempotently via `composeCreationLockID` so it should make no difference to the client. 
author_association: CONTRIBUTOR
user: login: ollevche
created_at: 2025-02-04T14:26:08Z
body: The problem I was describing lies in the usage of advisory lock with the state ID produced by the index. The following query does exactly that:

https://github.com/opentofu/opentofu/blob/60fdd359d59915249e04155f6c02806a0206667e/internal/backend/remote-state/pg/client.go#L98-L99

Imagine, a user sets up two different configurations with two different backends, which share the same underlying database, but have distinct schemas / tables and indexes. It is then possible to have multiple states with the same ID from different configurations (backends) since the indexes (sequences) are different.

Let's say we have state A with ID = 1 from the first configuration and state B with ID = 1 from the second configuration. States A and B are distinct, however share the same ID, which will be used to acquire the advisory lock (which is shared across all the schemas, on the database level). This is where additional collisions occur.

> We will only run into this if people explicitly configure the new configuration fields. They default to what was before. So this challenge would only present itself to people looking to explicitly do this.

Yep, I absolutely agree with that statement. What I meant is that now it is easier for user to break the setup by just setting an index name (without knowing about internal locking behavior). In my opinion, it is unsafe to allow users to control this. Also, I am not sure I understand the use-case under that request.

In the original issue, it was requested to reduce the number of schemas (which could be solved by a table name param). I don't see any problem with this, however custom index names could be more harmful, than useful.

author_association: CONTRIBUTOR
user: login: ollevche
created_at: 2025-02-04T14:31:45Z
body: I think I understand why it is so confusing.

`pg` backend doesn't use `LockInfo.ID` on the `Lock` call and also it doesn't use the `id` passed into the `Unlock` call. I don't know the reason behind it, but it is clearly unobvious behaviour and could create some confusion in that discussion.

Somewhy, the actual lock ID is written into the `Path` field and that value is used to unlock later on (from the "cached" version of LockInfo):

https://github.com/opentofu/opentofu/blob/60fdd359d59915249e04155f6c02806a0206667e/internal/backend/remote-state/pg/client.go#L139-L140
author_association: CONTRIBUTOR
user: login: taliesins
created_at: 2025-02-04T23:34:18Z
body: @ollevche  I think because we are using a sequence that is shared between all the schemas a change to multiple tables won't effect this. 

The only two paths that set `.Path` are for locking when there isn't a workspace record yet (creation lock) or we want to update an existing workspace (workspace lock). 

I think there is the potential to simplify to a single lock by using "schema"+"|" + "table" + "|" + "workspace" instead, which is what id is basically an alias of. 

What do you think?

![pg](https://github.com/user-attachments/assets/c9420e5f-9453-4993-9ecb-66134cabd5a2)

author_association: CONTRIBUTOR
user: login: ollevche
created_at: 2025-02-05T13:22:12Z
body: Hey @taliesins thanks for the additional context. 

You are totally right. I initially thought the idea was to allow users to specify a sequence as well as the index in the configuration. After re-reading the PR I understand that's not the case. Sorry for the confusion.

> I think there is the potential to simplify to a single lock by using "schema"+"|" + "table" + "|" + "workspace" instead, which is what id is basically an alias of.

Creation locks must be scoped to the entire table so we shouldn't map it to a specific row (workspace), so I believe the current approach is fine. 
author_association: CONTRIBUTOR
user: login: taliesins
created_at: 2025-02-06T21:10:13Z
body: @ollevche or @abstractionfactory any chance I can get approval to run workflow?
author_association: CONTRIBUTOR
user: login: ollevche
created_at: 2025-02-07T10:20:20Z
body: I started the checks and also ensured the tests were passing. We are moving toward automated acceptance tests, including automatically runnable pg tests, so we will eventually fix that.
author_association: CONTRIBUTOR
user: login: taliesins
created_at: 2025-03-21T16:35:22Z
body: @ollevche can you approve this so that the tests can run
author_association: CONTRIBUTOR
user: login: cam72cam
created_at: 2025-04-08T11:33:38Z
body: @taliesins sorry for the delay, running now
author_association: MEMBER
user: login: cam72cam
created_at: 2025-04-09T12:21:04Z
body: @taliesins can you add a changelog entry?  Once that's in, I think we are good to merge.
author_association: MEMBER
user: login: taliesins
created_at: 2025-04-09T19:29:15Z
body: @cam72cam if you could approve the workflow.
author_association: CONTRIBUTOR
user: login: cam72cam
created_at: 2025-04-09T21:21:14Z
body: Thanks for the submission and your patience @taliesins !
author_association: MEMBER