Repository: apache/iceberg-go
Pull request #308 (2025-02-19 05:17:58)

fix(catalog/rest): Nessie CreateTable
I am using Nessie as a catalog, while creating table i was getting below error

```
Failed to create table: failed to create table: WebApplicationException: HTTP 400 Bad Request
```

When i checked nessie logs, i found this error
```
nessie  | Caused by: com.fasterxml.jackson.databind.exc.ValueInstantiationException: Cannot construct instance of `org.projectnessie.catalog.formats.iceberg.rest.ImmutableIcebergCreateTableRequest`, problem: Cannot build IcebergCreateTableRequest, some of required attributes are not set [stageCreate]
```

**The root cause is:**
In the Go library's struct definition, StageCreate is marked as optional with omitempty
When false is set as the value, the JSON marshaler sees it as the zero value for bool and due to omitempty, it omits the field entirely from the JSON
However, the Nessie REST API requires this field to be explicitly set, even if it's false

This small change fixes the issue


snazy: The Iceberg REST spec is ambiguous there.
The behaviors for stage-create == true and == false are defined - but no default value/behavior is defined - and the property is optional.
To me that's an issue in the Iceberg REST spec surfaced the Go implementation
zeroshade: @snazy Do you know if any other REST implementations have an issue with providing `stage-create: false` instead of leaving it out?
shubham-tomar: Hi @zeroshade, i have added RenameTable Fix too as discussed

Patch:
diff --git a/README.md b/README.md
index 90a3b20..4a5fe13 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ $ cd iceberg-go/cmd/iceberg && go build .
 | Alter Table              |      |      |          |      |  X  |
 | Set Table Properties     |  X   |      |          |      |  X  |
 | Create Namespace         |  X   |      |          |  X   |  X  |
-| Check Namespace Exists   |  X   |      |          |      |  X  |
+| Check Namespace Exists   |  X   |      |          |  X   |  X  |
 | Drop Namespace           |  X   |      |          |  X   |  X  |
 | Set Namespace Properties |  X   |      |          |  X   |  X  |
 | List View                |  X   |      |          |      |     |
diff --git a/catalog/catalog.go b/catalog/catalog.go
index 6e9a98b..bc04a6d 100644
--- a/catalog/catalog.go
+++ b/catalog/catalog.go
@@ -31,6 +31,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"iter"
 	"maps"
 	"strings"
 
@@ -79,7 +80,7 @@ type Catalog interface {
 	CommitTable(context.Context, *table.Table, []table.Requirement, []table.Update) (table.Metadata, string, error)
 	// ListTables returns a list of table identifiers in the catalog, with the returned
 	// identifiers containing the information required to load the table via that catalog.
-	ListTables(ctx context.Context, namespace table.Identifier) ([]table.Identifier, error)
+	ListTables(ctx context.Context, namespace table.Identifier) iter.Seq2[table.Identifier, error]
 	// LoadTable loads a table from the catalog and returns a Table with the metadata.
 	LoadTable(ctx context.Context, identifier table.Identifier, props iceberg.Properties) (*table.Table, error)
 	// DropTable tells the catalog to drop the table entirely.
diff --git a/catalog/glue/glue.go b/catalog/glue/glue.go
index 41f61c8..5b9cb0e 100644
--- a/catalog/glue/glue.go
+++ b/catalog/glue/glue.go
@@ -21,6 +21,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"iter"
 	"strconv"
 	_ "unsafe"
 
@@ -155,33 +156,34 @@ func NewCatalog(opts ...Option) *Catalog {
 // ListTables returns a list of Iceberg tables in the given Glue database.
 //
 // The namespace should just contain the Glue database name.
-func (c *Catalog) ListTables(ctx context.Context, namespace table.Identifier) ([]table.Identifier, error) {
-	database, err := identifierToGlueDatabase(namespace)
-	if err != nil {
-		return nil, err
-	}
-
-	params := &glue.GetTablesInput{CatalogId: c.catalogId, DatabaseName: aws.String(database)}
-
-	var icebergTables []table.Identifier
-
-	for {
-		tblsRes, err := c.glueSvc.GetTables(ctx, params)
+func (c *Catalog) ListTables(ctx context.Context, namespace table.Identifier) iter.Seq2[table.Identifier, error] {
+	return func(yield func(table.Identifier, error) bool) {
+		database, err := identifierToGlueDatabase(namespace)
 		if err != nil {
-			return nil, fmt.Errorf("failed to list tables in namespace %s: %w", database, err)
+			yield(table.Identifier{}, err)
+			return
 		}
 
-		icebergTables = append(icebergTables,
-			filterTableListByType(database, tblsRes.TableList, glueTypeIceberg)...)
+		paginator := glue.NewGetTablesPaginator(c.glueSvc, &glue.GetTablesInput{
+			CatalogId:    c.catalogId,
+			DatabaseName: aws.String(database),
+		})
 
-		if tblsRes.NextToken == nil {
-			break
-		}
+		for paginator.HasMorePages() {
+			page, err := paginator.NextPage(ctx)
+			if err != nil {
+				yield(table.Identifier{}, fmt.Errorf("failed to list tables in namespace %s: %w", database, err))
+				return
+			}
 
-		params.NextToken = tblsRes.NextToken
+			icebergTables := filterTableListByType(database, page.TableList, glueTypeIceberg)
+			for _, tbl := range icebergTables {
+				if !yield(tbl, nil) {
+					return
+				}
+			}
+		}
 	}
-
-	return icebergTables, nil
 }
 
 // LoadTable loads a table from the catalog table details.
@@ -363,6 +365,19 @@ func (c *Catalog) CreateNamespace(ctx context.Context, namespace table.Identifie
 	return nil
 }
 
+func (c *Catalog) CheckNamespaceExists(ctx context.Context, namespace table.Identifier) (bool, error) {
+	databaseName, err := identifierToGlueDatabase(namespace)
+	if err != nil {
+		return false, err
+	}
+
+	_, err = c.getDatabase(ctx, databaseName)
+	if err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
 // DropNamespace deletes an Iceberg namespace from the Glue catalog.
 func (c *Catalog) DropNamespace(ctx context.Context, namespace table.Identifier) error {
 	databaseName, err := identifierToGlueDatabase(namespace)
@@ -543,14 +558,12 @@ func DatabaseIdentifier(database string) table.Identifier {
 
 func filterTableListByType(database string, tableList []types.Table, tableType string) []table.Identifier {
 	var filtered []table.Identifier
-
 	for _, tbl := range tableList {
 		if tbl.Parameters[tableTypePropsKey] != tableType {
 			continue
 		}
 		filtered = append(filtered, TableIdentifier(database, aws.ToString(tbl.Name)))
 	}
-
 	return filtered
 }
 
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index d097c9a..7899c0d 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -155,7 +155,7 @@ type createTableRequest struct {
 	Location      string                 `json:"location,omitempty"`
 	PartitionSpec *iceberg.PartitionSpec `json:"partition-spec,omitempty"`
 	WriteOrder    *table.SortOrder       `json:"write-order,omitempty"`
-	StageCreate   bool                   `json:"stage-create,omitempty"`
+	StageCreate   bool                   `json:"stage-create"`
 	Props         iceberg.Properties     `json:"properties,omitempty"`
 }
 
@@ -649,27 +649,60 @@ func (r *Catalog) tableFromResponse(ctx context.Context, identifier []string, me
 	return table.New(id, metadata, loc, iofs), nil
 }
 
-func (r *Catalog) ListTables(ctx context.Context, namespace table.Identifier) ([]table.Identifier, error) {
-	if err := checkValidNamespace(namespace); err != nil {
-		return nil, err
+func (r *Catalog) ListTables(ctx context.Context, namespace table.Identifier) iter.Seq2[table.Identifier, error] {
+	return func(yield func(table.Identifier, error) bool) {
+		pageSize := r.getPageSize(ctx)
+		var pageToken string
+
+		for {
+			tables, nextPageToken, err := r.listTablesPage(ctx, namespace, pageToken, pageSize)
+			if err != nil {
+				yield(table.Identifier{}, err)
+				return
+			}
+			for _, tbl := range tables {
+				if !yield(tbl, nil) {
+					return
+				}
+			}
+			if nextPageToken == "" {
+				return
+			}
+			pageToken = nextPageToken
+		}
 	}
+}
 
+func (r *Catalog) listTablesPage(ctx context.Context, namespace table.Identifier, pageToken string, pageSize int) ([]table.Identifier, string, error) {
+	if err := checkValidNamespace(namespace); err != nil {
+		return nil, "", err
+	}
 	ns := strings.Join(namespace, namespaceSeparator)
-	path := []string{"namespaces", ns, "tables"}
+	uri := r.baseURI.JoinPath("namespaces", ns, "tables")
+
+	v := url.Values{}
+	if pageSize >= 0 {
+		v.Set("page-size", strconv.Itoa(pageSize))
+	}
+	if pageToken != "" {
+		v.Set("page-token", pageToken)
+	}
 
+	uri.RawQuery = v.Encode()
 	type resp struct {
-		Identifiers []identifier `json:"identifiers"`
+		Identifiers   []identifier `json:"identifiers"`
+		NextPageToken string       `json:"next-page-token,omitempty"`
 	}
-	rsp, err := doGet[resp](ctx, r.baseURI, path, r.cl, map[int]error{http.StatusNotFound: catalog.ErrNoSuchNamespace})
+	rsp, err := doGet[resp](ctx, uri, []string{}, r.cl, map[int]error{http.StatusNotFound: catalog.ErrNoSuchNamespace})
 	if err != nil {
-		return nil, err
+		return nil, "", err
 	}
-
 	out := make([]table.Identifier, len(rsp.Identifiers))
 	for i, id := range rsp.Identifiers {
 		out[i] = append(id.Namespace, id.Name)
 	}
-	return out, nil
+	return out, rsp.NextPageToken, nil
+
 }
 
 func splitIdentForPath(ident table.Identifier) (string, string, error) {
@@ -866,19 +899,19 @@ func (r *Catalog) PurgeTable(ctx context.Context, identifier table.Identifier) e
 
 func (r *Catalog) RenameTable(ctx context.Context, from, to table.Identifier) (*table.Table, error) {
 	type payload struct {
-		From identifier `json:"from"`
-		To   identifier `json:"to"`
+		Source      identifier `json:"source"`
+		Destination identifier `json:"destination"`
 	}
-	f := identifier{
+	src := identifier{
 		Namespace: catalog.NamespaceFromIdent(from),
 		Name:      catalog.TableNameFromIdent(from),
 	}
-	t := identifier{
+	dst := identifier{
 		Namespace: catalog.NamespaceFromIdent(to),
 		Name:      catalog.TableNameFromIdent(to),
 	}
 
-	_, err := doPost[payload, any](ctx, r.baseURI, []string{"tables", "rename"}, payload{From: f, To: t}, r.cl,
+	_, err := doPost[payload, any](ctx, r.baseURI, []string{"tables", "rename"}, payload{Source: src, Destination: dst}, r.cl,
 		map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable})
 	if err != nil {
 		return nil, err
diff --git a/catalog/sql/sql.go b/catalog/sql/sql.go
index 22edee1..657576b 100644
--- a/catalog/sql/sql.go
+++ b/catalog/sql/sql.go
@@ -22,6 +22,7 @@ import (
 	"database/sql"
 	"errors"
 	"fmt"
+	"iter"
 	"maps"
 	"path"
 	"slices"
@@ -524,9 +525,15 @@ func (c *Catalog) DropNamespace(ctx context.Context, namespace table.Identifier)
 		return fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, nsToDelete)
 	}
 
-	tbls, err := c.ListTables(ctx, namespace)
-	if err != nil {
-		return err
+	tbls := make([]table.Identifier, 0)
+	iter := c.ListTables(ctx, namespace)
+
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			return err
+		}
+		break // there is already at least a table
 	}
 
 	if len(tbls) > 0 {
@@ -576,7 +583,23 @@ func (c *Catalog) LoadNamespaceProperties(ctx context.Context, namespace table.I
 	})
 }
 
-func (c *Catalog) ListTables(ctx context.Context, namespace table.Identifier) ([]table.Identifier, error) {
+func (c *Catalog) ListTables(ctx context.Context, namespace table.Identifier) iter.Seq2[table.Identifier, error] {
+	tables, err := c.listTablesAll(ctx, namespace)
+	if err != nil {
+		return func(yield func(table.Identifier, error) bool) {
+			yield(table.Identifier{}, err)
+		}
+	}
+	return func(yield func(table.Identifier, error) bool) {
+		for _, t := range tables {
+			if !yield(t, nil) {
+				return
+			}
+		}
+	}
+}
+
+func (c *Catalog) listTablesAll(ctx context.Context, namespace table.Identifier) ([]table.Identifier, error) {
 	if len(namespace) > 0 {
 		exists, err := c.namespaceExists(ctx, strings.Join(namespace, "."))
 		if err != nil {
diff --git a/cmd/iceberg/main.go b/cmd/iceberg/main.go
index da74601..31845ee 100644
--- a/cmd/iceberg/main.go
+++ b/cmd/iceberg/main.go
@@ -273,10 +273,12 @@ func list(ctx context.Context, output Output, cat catalog.Catalog, parent string
 	}
 
 	if len(ids) == 0 && parent != "" {
-		ids, err = cat.ListTables(ctx, prnt)
-		if err != nil {
-			output.Error(err)
-			os.Exit(1)
+		iter := cat.ListTables(ctx, prnt)
+		for id, err := range iter {
+			ids = append(ids, id)
+			if err != nil {
+				output.Error(err)
+			}
 		}
 	}
 	output.Identifiers(ids)


Test patch:
diff --git a/catalog/glue/glue_test.go b/catalog/glue/glue_test.go
index 4c8aa48..2c6516f 100644
--- a/catalog/glue/glue_test.go
+++ b/catalog/glue/glue_test.go
@@ -20,10 +20,12 @@ package glue
 import (
 	"context"
 	"errors"
+	"fmt"
 	"os"
 	"testing"
 
 	"github.com/apache/iceberg-go/catalog"
+	"github.com/apache/iceberg-go/table"
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/config"
 	"github.com/aws/aws-sdk-go-v2/service/glue"
@@ -81,13 +83,42 @@ func (m *mockGlueClient) UpdateDatabase(ctx context.Context, params *glue.Update
 	return args.Get(0).(*glue.UpdateDatabaseOutput), args.Error(1)
 }
 
-var testIcebergGlueTable = types.Table{
+var testIcebergGlueTable1 = types.Table{
 	Name: aws.String("test_table"),
 	Parameters: map[string]string{
 		tableTypePropsKey:        "ICEBERG",
 		metadataLocationPropsKey: "s3://test-bucket/test_table/metadata/abc123-123.metadata.json",
 	},
 }
+var testIcebergGlueTable2 = types.Table{
+	Name: aws.String("test_table2"),
+	Parameters: map[string]string{
+		tableTypePropsKey:        "ICEBERG",
+		metadataLocationPropsKey: "s3://test-bucket/test_table/metadata/abc456-456.metadata.json",
+	},
+}
+
+var testIcebergGlueTable3 = types.Table{
+	Name: aws.String("test_table3"),
+	Parameters: map[string]string{
+		tableTypePropsKey:        "ICEBERG",
+		metadataLocationPropsKey: "s3://test-bucket/test_table/metadata/abc789-789.metadata.json",
+	},
+}
+var testIcebergGlueTable4 = types.Table{
+	Name: aws.String("test_table4"),
+	Parameters: map[string]string{
+		tableTypePropsKey:        "ICEBERG",
+		metadataLocationPropsKey: "s3://test-bucket/test_table/metadata/abc123-789.metadata.json",
+	},
+}
+var testIcebergGlueTable5 = types.Table{
+	Name: aws.String("test_table5"),
+	Parameters: map[string]string{
+		tableTypePropsKey:        "ICEBERG",
+		metadataLocationPropsKey: "s3://test-bucket/test_table/metadata/abc12345-789.metadata.json",
+	},
+}
 
 var testNonIcebergGlueTable = types.Table{
 	Name: aws.String("other_table"),
@@ -104,7 +135,7 @@ func TestGlueGetTable(t *testing.T) {
 	mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
 		DatabaseName: aws.String("test_database"),
 		Name:         aws.String("test_table"),
-	}, mock.Anything).Return(&glue.GetTableOutput{Table: &testIcebergGlueTable}, nil)
+	}, mock.Anything).Return(&glue.GetTableOutput{Table: &testIcebergGlueTable1}, nil)
 
 	glueCatalog := &Catalog{
 		glueSvc: mockGlueSvc,
@@ -123,19 +154,136 @@ func TestGlueListTables(t *testing.T) {
 	mockGlueSvc.On("GetTables", mock.Anything, &glue.GetTablesInput{
 		DatabaseName: aws.String("test_database"),
 	}, mock.Anything).Return(&glue.GetTablesOutput{
-		TableList: []types.Table{testIcebergGlueTable, testNonIcebergGlueTable},
+		TableList: []types.Table{testIcebergGlueTable1, testNonIcebergGlueTable},
 	}, nil).Once()
 
 	glueCatalog := &Catalog{
 		glueSvc: mockGlueSvc,
 	}
 
-	tables, err := glueCatalog.ListTables(context.TODO(), DatabaseIdentifier("test_database"))
-	assert.NoError(err)
-	assert.Len(tables, 1)
-	assert.Equal([]string{"test_database", "test_table"}, tables[0])
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+	iter := glueCatalog.ListTables(context.TODO(), DatabaseIdentifier("test_database"))
+
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			lastErr = err
+		}
+	}
+	assert.NoError(lastErr)
+	assert.Len(tbls, 1)
+	assert.Equal([]string{"test_database", "test_table"}, tbls[0])
+}
+
+func TestGlueListTablesPagination(t *testing.T) {
+	assert := require.New(t)
+
+	mockGlueSvc := &mockGlueClient{}
+
+	// First page
+	mockGlueSvc.On("GetTables", mock.Anything, &glue.GetTablesInput{
+		DatabaseName: aws.String("test_database"),
+	}, mock.Anything).Return(&glue.GetTablesOutput{
+		TableList: []types.Table{
+			testIcebergGlueTable1,
+			testIcebergGlueTable2,
+		},
+		NextToken: aws.String("token1"),
+	}, nil).Once()
+
+	// Second page
+	mockGlueSvc.On("GetTables", mock.Anything, &glue.GetTablesInput{
+		DatabaseName: aws.String("test_database"),
+		NextToken:    aws.String("token1"),
+	}, mock.Anything).Return(&glue.GetTablesOutput{
+		TableList: []types.Table{
+			testIcebergGlueTable3,
+			testIcebergGlueTable4,
+		},
+		NextToken: aws.String("token2"),
+	}, nil).Once()
+
+	// Third page
+	mockGlueSvc.On("GetTables", mock.Anything, &glue.GetTablesInput{
+		DatabaseName: aws.String("test_database"),
+		NextToken:    aws.String("token2"),
+	}, mock.Anything).Return(&glue.GetTablesOutput{
+		TableList: []types.Table{
+			testIcebergGlueTable5,
+			testNonIcebergGlueTable,
+		},
+	}, nil).Once()
+
+	glueCatalog := &Catalog{
+		glueSvc: mockGlueSvc,
+	}
+
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+	iter := glueCatalog.ListTables(context.TODO(), DatabaseIdentifier("test_database"))
+
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			lastErr = err
+		}
+	}
+
+	assert.NoError(lastErr)
+	assert.Len(tbls, 5) // Only Iceberg tables should be included
+	assert.Equal([]string{"test_database", "test_table"}, tbls[0])
+	assert.Equal([]string{"test_database", "test_table2"}, tbls[1])
+	assert.Equal([]string{"test_database", "test_table3"}, tbls[2])
+	assert.Equal([]string{"test_database", "test_table4"}, tbls[3])
+	assert.Equal([]string{"test_database", "test_table5"}, tbls[4])
+
+	mockGlueSvc.AssertExpectations(t)
 }
 
+func TestGlueListTablesError(t *testing.T) {
+	assert := require.New(t)
+
+	mockGlueSvc := &mockGlueClient{}
+
+	// First page succeeds
+	mockGlueSvc.On("GetTables", mock.Anything, &glue.GetTablesInput{
+		DatabaseName: aws.String("test_database"),
+	}, mock.Anything).Return(&glue.GetTablesOutput{
+		TableList: []types.Table{
+			testIcebergGlueTable1,
+		},
+		NextToken: aws.String("token1"),
+	}, nil).Once()
+
+	mockGlueSvc.On("GetTables", mock.Anything, &glue.GetTablesInput{
+		DatabaseName: aws.String("test_database"),
+		NextToken:    aws.String("token1"),
+	}, mock.Anything).Return(&glue.GetTablesOutput{}, fmt.Errorf("token expired")).Once()
+
+	glueCatalog := &Catalog{
+		glueSvc: mockGlueSvc,
+	}
+
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+	iter := glueCatalog.ListTables(context.TODO(), DatabaseIdentifier("test_database"))
+
+	for tbl, err := range iter {
+		if err != nil {
+			lastErr = err
+			break
+		}
+		tbls = append(tbls, tbl)
+	}
+
+	assert.Error(lastErr)
+	assert.Contains(lastErr.Error(), "token expired")
+	assert.Len(tbls, 1)
+	assert.Equal([]string{"test_database", "test_table"}, tbls[0])
+
+	mockGlueSvc.AssertExpectations(t)
+}
 func TestGlueListNamespaces(t *testing.T) {
 	assert := require.New(t)
 
@@ -175,7 +323,7 @@ func TestGlueDropTable(t *testing.T) {
 		DatabaseName: aws.String("test_database"),
 		Name:         aws.String("test_table"),
 	}, mock.Anything).Return(&glue.GetTableOutput{
-		Table: &testIcebergGlueTable,
+		Table: &testIcebergGlueTable1,
 	}, nil).Once()
 
 	mockGlueSvc.On("DeleteTable", mock.Anything, &glue.DeleteTableInput{
@@ -248,6 +396,47 @@ func TestGlueDropNamespace(t *testing.T) {
 	assert.NoError(err)
 }
 
+func TestGlueCheckNamespaceExists(t *testing.T) {
+	assert := require.New(t)
+	mockGlueSvc := &mockGlueClient{}
+	mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+		Name: aws.String("test_namespace"),
+	}, mock.Anything).Return(&glue.GetDatabaseOutput{
+		Database: &types.Database{
+			Name: aws.String("test_namespace"),
+			Parameters: map[string]string{
+				"database_type": "ICEBERG",
+			},
+		},
+	}, nil).Once()
+	glueCatalog := &Catalog{
+		glueSvc: mockGlueSvc,
+	}
+	exists, err := glueCatalog.CheckNamespaceExists(context.TODO(), DatabaseIdentifier("test_namespace"))
+	assert.NoError(err)
+	assert.True(exists)
+
+}
+
+func TestGlueCheckNamespaceNotExists(t *testing.T) {
+	assert := require.New(t)
+	mockGlueSvc := &mockGlueClient{}
+
+	mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+		Name: aws.String("nonexistent_namespace"),
+	}, mock.Anything).Return(&glue.GetDatabaseOutput{},
+		&types.EntityNotFoundException{Message: aws.String("Database not found")}).Once()
+
+	glueCatalog := &Catalog{
+		glueSvc: mockGlueSvc,
+	}
+
+	exists, err := glueCatalog.CheckNamespaceExists(context.TODO(), DatabaseIdentifier("nonexistent_namespace"))
+	assert.Error(err)
+	assert.False(exists)
+	assert.ErrorContains(err, "Database not found")
+}
+
 func TestGlueUpdateNamespaceProperties(t *testing.T) {
 	tests := []struct {
 		name        string
@@ -534,9 +723,18 @@ func TestGlueListTablesIntegration(t *testing.T) {
 
 	catalog := NewCatalog(WithAwsConfig(awscfg))
 
-	tables, err := catalog.ListTables(context.TODO(), DatabaseIdentifier(os.Getenv("TEST_DATABASE_NAME")))
-	assert.NoError(err)
-	assert.Equal([]string{os.Getenv("TEST_DATABASE_NAME"), os.Getenv("TEST_TABLE_NAME")}, tables[1])
+	iter := catalog.ListTables(context.TODO(), DatabaseIdentifier(os.Getenv("TEST_DATABASE_NAME")))
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			lastErr = err
+		}
+	}
+
+	assert.NoError(lastErr)
+	assert.Equal([]string{os.Getenv("TEST_DATABASE_NAME"), os.Getenv("TEST_TABLE_NAME")}, tbls[1])
 }
 
 func TestGlueLoadTableIntegration(t *testing.T) {
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index 32d96ad..6962f60 100644
--- a/catalog/rest/rest_test.go
+++ b/catalog/rest/rest_test.go
@@ -37,8 +37,9 @@ import (
 )
 
 const (
-	TestCreds = "client:secret"
-	TestToken = "some_jwt_token"
+	TestCreds       = "client:secret"
+	TestToken       = "some_jwt_token"
+	defaultPageSize = 20
 )
 
 var (
@@ -234,13 +235,17 @@ func (r *RestCatalogSuite) TestToken401() {
 
 func (r *RestCatalogSuite) TestListTables200() {
 	namespace := "examples"
+	customPageSize := 100
 	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/tables", func(w http.ResponseWriter, req *http.Request) {
 		r.Require().Equal(http.MethodGet, req.Method)
 
 		for k, v := range TestHeaders {
 			r.Equal(v, req.Header.Values(k))
 		}
-
+		pageToken := req.URL.Query().Get("page-token")
+		pageSize := req.URL.Query().Get("page-size")
+		r.Equal("", pageToken)
+		r.Equal(strconv.Itoa(customPageSize), pageSize)
 		json.NewEncoder(w).Encode(map[string]any{
 			"identifiers": []any{
 				map[string]any{
@@ -254,9 +259,22 @@ func (r *RestCatalogSuite) TestListTables200() {
 	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
 	r.Require().NoError(err)
 
-	tables, err := cat.ListTables(context.Background(), catalog.ToIdentifier(namespace))
-	r.Require().NoError(err)
-	r.Equal([]table.Identifier{{"examples", "fooshare"}}, tables)
+	ctx := cat.SetPageSize(context.Background(), customPageSize)
+
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+
+	iter := cat.ListTables(ctx, catalog.ToIdentifier(namespace))
+
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			lastErr = err
+			r.FailNow("unexpected error:", err)
+		}
+	}
+	r.Require().NoError(lastErr)
+	r.Equal([]table.Identifier{{"examples", "fooshare"}}, tbls)
 }
 
 func (r *RestCatalogSuite) TestListTablesPrefixed200() {
@@ -289,6 +307,10 @@ func (r *RestCatalogSuite) TestListTablesPrefixed200() {
 		for k, v := range TestHeaders {
 			r.Equal(v, req.Header.Values(k))
 		}
+		pageToken := req.URL.Query().Get("page-token")
+		pageSize := req.URL.Query().Get("page-size")
+		r.Equal("", pageToken)
+		r.Equal(strconv.Itoa(defaultPageSize), pageSize)
 
 		json.NewEncoder(w).Encode(map[string]any{
 			"identifiers": []any{
@@ -309,11 +331,172 @@ func (r *RestCatalogSuite) TestListTablesPrefixed200() {
 	r.NotNil(cat)
 	r.Equal(r.configVals.Get("warehouse"), "s3://some-bucket")
 
-	tables, err := cat.ListTables(context.Background(), catalog.ToIdentifier(namespace))
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+
+	iter := cat.ListTables(context.Background(), catalog.ToIdentifier(namespace))
+
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			lastErr = err
+			r.FailNow("unexpected error:", err)
+		}
+	}
+	r.Require().NoError(lastErr)
+	r.Equal([]table.Identifier{{"examples", "fooshare"}}, tbls)
+}
+func (r *RestCatalogSuite) TestListTablesPagination() {
+	defaultPageSize := 20
+	namespace := "accounting"
+	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/tables", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodGet, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		pageToken := req.URL.Query().Get("page-token")
+		pageSize := req.URL.Query().Get("page-size")
+		r.Equal(strconv.Itoa(defaultPageSize), pageSize)
+
+		var response map[string]any
+		if pageToken == "" {
+			response = map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{namespace},
+						"name":      "paid1",
+					},
+					map[string]any{
+						"namespace": []string{namespace},
+						"name":      "paid2",
+					},
+				},
+				"next-page-token": "token1",
+			}
+		} else if pageToken == "token1" {
+			r.Equal("token1", pageToken)
+			response = map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{namespace},
+						"name":      "pending1",
+					},
+					map[string]any{
+						"namespace": []string{namespace},
+						"name":      "pending2",
+					},
+				},
+				"next-page-token": "token2",
+			}
+		} else {
+			r.Equal("token2", pageToken)
+			response = map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{namespace},
+						"name":      "owned",
+					},
+				},
+			}
+		}
+
+		json.NewEncoder(w).Encode(response)
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
 	r.Require().NoError(err)
-	r.Equal([]table.Identifier{{"examples", "fooshare"}}, tables)
+
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+	iter := cat.ListTables(context.Background(), catalog.ToIdentifier(namespace))
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			lastErr = err
+			r.FailNow("unexpected error:", err)
+		}
+	}
+
+	r.Equal([]table.Identifier{
+		{"accounting", "paid1"},
+		{"accounting", "paid2"},
+		{"accounting", "pending1"},
+		{"accounting", "pending2"},
+		{"accounting", "owned"},
+	}, tbls)
+	r.Require().NoError(lastErr)
 }
+func (r *RestCatalogSuite) TestListTablesPaginationErrorOnSubsequentPage() {
+	namespace := "accounting"
+	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/tables", func(w http.ResponseWriter, req *http.Request) {
+		r.Require().Equal(http.MethodGet, req.Method)
+
+		for k, v := range TestHeaders {
+			r.Equal(v, req.Header.Values(k))
+		}
+
+		pageToken := req.URL.Query().Get("page-token")
+
+		// First page succeeds
+		if pageToken == "" {
+			json.NewEncoder(w).Encode(map[string]any{
+				"identifiers": []any{
+					map[string]any{
+						"namespace": []string{namespace},
+						"name":      "paid1",
+					},
+					map[string]any{
+						"namespace": []string{namespace},
+						"name":      "paid2",
+					},
+				},
+				"next-page-token": "token1",
+			})
+			return
+		}
+
+		// Second page fails with an error
+		if pageToken == "token1" {
+			w.WriteHeader(http.StatusNotFound)
+			json.NewEncoder(w).Encode(map[string]any{
+				"error": map[string]any{
+					"message": "Token expired or invalid",
+					"type":    "NoSuchPageTokenException",
+					"code":    404,
+				},
+			})
+			return
+		}
+
+		r.FailNow("unexpected page token:", pageToken)
+	})
+
+	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
+	r.Require().NoError(err)
+
+	tbls := make([]table.Identifier, 0)
+	var lastErr error
+	iter := cat.ListTables(context.Background(), catalog.ToIdentifier(namespace))
+	for tbl, err := range iter {
+		if err != nil {
+			lastErr = err
+			break
+		}
+		tbls = append(tbls, tbl)
+	}
 
+	// Check that we got the views from the first page
+	r.Equal([]table.Identifier{
+		{"accounting", "paid1"},
+		{"accounting", "paid2"},
+	}, tbls)
+
+	// Check that we got the error from the second page
+	r.Error(lastErr)
+	r.ErrorContains(lastErr, "Token expired or invalid")
+}
 func (r *RestCatalogSuite) TestListTables404() {
 	namespace := "examples"
 	r.mux.HandleFunc("/v1/namespaces/"+namespace+"/tables", func(w http.ResponseWriter, req *http.Request) {
@@ -323,6 +506,10 @@ func (r *RestCatalogSuite) TestListTables404() {
 			r.Equal(v, req.Header.Values(k))
 		}
 
+		pageToken := req.URL.Query().Get("page-token")
+		pageSize := req.URL.Query().Get("page-size")
+		r.Equal("", pageToken)
+		r.Equal(strconv.Itoa(defaultPageSize), pageSize)
 		w.WriteHeader(http.StatusNotFound)
 		json.NewEncoder(w).Encode(map[string]any{
 			"error": map[string]any{
@@ -336,9 +523,19 @@ func (r *RestCatalogSuite) TestListTables404() {
 	cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, rest.WithOAuthToken(TestToken))
 	r.Require().NoError(err)
 
-	_, err = cat.ListTables(context.Background(), catalog.ToIdentifier(namespace))
-	r.ErrorIs(err, catalog.ErrNoSuchNamespace)
-	r.ErrorContains(err, "Namespace does not exist: personal in warehouse 8bcb0838-50fc-472d-9ddb-8feb89ef5f1e")
+	var lastErr error
+	tbls := make([]table.Identifier, 0)
+
+	iter := cat.ListTables(context.Background(), catalog.ToIdentifier(namespace))
+
+	for tbl, err := range iter {
+		tbls = append(tbls, tbl)
+		if err != nil {
+			lastErr = err
+		}
+	}
+	r.ErrorIs(lastErr, catalog.ErrNoSuchNamespace)
+	r.ErrorContains(lastErr, "Namespace does not exist")
 }
 
 func (r *RestCatalogSuite) TestListNamespaces200() {
@@ -1018,20 +1215,20 @@ func (r *RestCatalogSuite) TestRenameTable200() {
 		}
 
 		var payload struct {
-			From struct {
+			Source struct {
 				Namespace []string `json:"namespace"`
 				Name      string   `json:"name"`
-			} `json:"from"`
-			To struct {
+			} `json:"source"`
+			Destination struct {
 				Namespace []string `json:"namespace"`
 				Name      string   `json:"name"`
-			} `json:"to"`
+			} `json:"destination"`
 		}
 		r.NoError(json.NewDecoder(req.Body).Decode(&payload))
-		r.Equal([]string{"fokko"}, payload.From.Namespace)
-		r.Equal("source", payload.From.Name)
-		r.Equal([]string{"fokko"}, payload.To.Namespace)
-		r.Equal("destination", payload.To.Name)
+		r.Equal([]string{"fokko"}, payload.Source.Namespace)
+		r.Equal("source", payload.Source.Name)
+		r.Equal([]string{"fokko"}, payload.Destination.Namespace)
+		r.Equal("destination", payload.Destination.Name)
 
 		w.WriteHeader(http.StatusOK)
 	})
diff --git a/catalog/sql/sql_test.go b/catalog/sql/sql_test.go
index 2cc4c61..5eb9502 100644
--- a/catalog/sql/sql_test.go
+++ b/catalog/sql/sql_test.go
@@ -486,18 +486,41 @@ func (s *SqliteCatalogTestSuite) TestListTables() {
 		_, err = cat.CreateTable(ctx, tbl2, tableSchemaNested)
 		s.Require().NoError(err)
 
-		tables, err := cat.ListTables(ctx, ns1)
-		s.Require().NoError(err)
-		s.Len(tables, 1)
-		s.Equal(tbl1, tables[0])
+		var lastErr error
+		tables := make([]table.Identifier, 0)
+		iter := cat.ListTables(ctx, ns1)
+		for tbl, err := range iter {
+			tables = append(tables, tbl)
+			if err != nil {
+				lastErr = err
+			}
+		}
 
-		tables, err = cat.ListTables(ctx, ns2)
-		s.Require().NoError(err)
+		s.Require().NoError(lastErr)
 		s.Len(tables, 1)
-		s.Equal(tbl2, tables[0])
+		s.Equal(tbl1, tables[0])
 
-		_, err = cat.ListTables(ctx, table.Identifier{"does_not_exist"})
-		s.ErrorIs(err, catalog.ErrNoSuchNamespace)
+		tables2 := make([]table.Identifier, 0)
+		iter = cat.ListTables(ctx, ns2)
+		for tbl, err := range iter {
+			tables2 = append(tables2, tbl)
+			if err != nil {
+				lastErr = err
+			}
+		}
+		s.Require().NoError(lastErr)
+		s.Len(tables2, 1)
+		s.Equal(tbl2, tables2[0])
+
+		table3 := make([]table.Identifier, 0)
+		iter = cat.ListTables(ctx, table.Identifier{"does_not_exist"})
+		for tbl, err := range iter {
+			table3 = append(table3, tbl)
+			if err != nil {
+				lastErr = err
+			}
+		}
+		s.ErrorIs(lastErr, catalog.ErrNoSuchNamespace)
 	}
 }