Repository: apache/iceberg-go
Pull request #1 (2023-08-15 20:14:24)

feat: schema and types
Initial dev setup scripts / github actions configs etc.

Plus initial schema and types implementation


Patch:
diff --git a/.asf.yaml b/.asf.yaml
new file mode 100644
index 0000000..ffd625d
--- /dev/null
+++ b/.asf.yaml
@@ -0,0 +1,34 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The format of this file is documented at
+# https://cwiki.apache.org/confluence/display/INFRA/Git+-+.asf.yaml+features
+
+github:
+  description: "Apache Iceberg - Go"
+  homepage: https://iceberg.apache.org/
+  labels:
+    - iceberg
+    - apache
+    - golang
+  features:
+    issues: true
+
+notifications:
+  commits:      commits@iceberg.apache.org
+  issues:       issues@iceberg.apache.org
+  pullrequests: issues@iceberg.apache.org
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..2ca0612
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,23 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+# 
+#   http://www.apache.org/licenses/LICENSE-2.0
+# 
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Files marked as export-ignore will be ignored from the release's
+# built via `git archive`. This simplifies the release script and 
+# uses an industry standard as opposed to possibly hard to read
+# shell scripts with many flags. Unfortunately, directories themselves
+# won't recursively ignore, so we need the top level directories
+# as well as their files.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/iceberg_bug_report.yml b/.github/ISSUE_TEMPLATE/iceberg_bug_report.yml
new file mode 100644
index 0000000..512bebc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/iceberg_bug_report.yml
@@ -0,0 +1,40 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+---
+name: Iceberg Bug report 🐞
+description: Problems, bugs and issues with Apache Iceberg
+labels: ["kind:bug"]
+body:
+  - type: dropdown
+    attributes:
+      label: Apache Iceberg version
+      description: What Apache Iceberg version are you using?
+      multiple: false
+      options:     
+        - "main (development)"
+    validations:
+      required: false
+  - type: textarea
+    attributes:
+      label: Please describe the bug 🐞
+      description: >
+        Please describe the problem, what to expect, and how to reproduce.
+        Feel free to include stacktraces and the Iceberg catalog configuration.
+        You can include files by dragging and dropping them here.
+    validations:
+      required: true
diff --git a/.github/ISSUE_TEMPLATE/iceberg_improvement.yml b/.github/ISSUE_TEMPLATE/iceberg_improvement.yml
new file mode 100644
index 0000000..60eddb5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/iceberg_improvement.yml
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+---
+name: Iceberg Improvement / Feature Request
+description: New features with Apache Iceberg
+labels: ["kind:feature request"]
+body:
+  - type: textarea
+    attributes:
+      label: Feature Request / Improvement
+      description: Please describe the feature and elaborate on the use case and motivation behind it
+    validations:
+      required: true     
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/iceberg_question.yml b/.github/ISSUE_TEMPLATE/iceberg_question.yml
new file mode 100644
index 0000000..a6111bb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/iceberg_question.yml
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+---
+name: Iceberg Question
+description: Questions around Apache Iceberg
+labels: ["kind:question"]
+body:
+  - type: markdown
+    attributes:
+      value: "Feel free to ask your question on [Slack](https://join.slack.com/t/apache-iceberg/shared_invite/zt-1uva9gyp1-TrLQl7o~nZ5PsTVgl6uoEQ) as well."
+  - type: textarea
+    attributes:
+      label: Question
+      description: What is your question?
+    validations:
+      required: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..14f8e04
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,24 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+version: 2
+updates:
+  - package-ecosystem: gomod
+    directory: /
+    schedule:
+      interval: "weekly"
+      day: "sunday"
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 0000000..4cd7825
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,25 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Pull request labeler Github Action Configuration: https://github.com/marketplace/actions/labeler
+INFRA:
+  - .asf.yaml
+  - .gitattributes
+  - .gitignore
+  - .github/**/*
+  - .pre-commit-config.yaml
+  - dev/**/*
diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml
new file mode 100644
index 0000000..5f4c42a
--- /dev/null
+++ b/.github/workflows/go-ci.yml
@@ -0,0 +1,57 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: Go
+
+on:
+  push:
+    branches:
+      - 'master'
+    tags:
+      - 'v**'          
+  pull_request:    
+
+concurrency:
+  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+  contents: read
+
+jobs:
+  lint-and-test:
+    name: ${{ matrix.os }} go${{ matrix.go }}
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        go: [ '1.20', '1.21' ]
+        os: [ 'ubuntu-latest', 'windows-latest', 'macos-latest' ]
+    steps:
+    - uses: actions/checkout@v3
+    - name: Install Go
+      uses: actions/setup-go@v4
+      with:
+        go-version: ${{ matrix.go }}
+        cache: true
+        cache-dependency-path: go.sum
+    - name: Install staticcheck
+      run: go install honnef.co/go/tools/cmd/staticcheck@latest
+    - name: Lint
+      run: staticcheck ./...
+    - name: Run tests
+      run: go test -v ./...
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
new file mode 100644
index 0000000..4a5b455
--- /dev/null
+++ b/.github/workflows/labeler.yml
@@ -0,0 +1,32 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: "Pull Request Labeler"
+on: pull_request_target
+
+permissions:
+  contents: read
+  pull-requests: write
+
+jobs:
+  triage:
+    runs-on: ubuntu-22.04
+    steps:
+    - uses: actions/labeler@v4
+      with:
+        repo-token: "${{ secrets.GITHUB_TOKEN }}"
+        sync-labels: true
diff --git a/.github/workflows/license_check.yml b/.github/workflows/license_check.yml
new file mode 100644
index 0000000..d727084
--- /dev/null
+++ b/.github/workflows/license_check.yml
@@ -0,0 +1,27 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: "Run License Check"
+on: pull_request
+
+jobs:
+  rat:
+    runs-on: ubuntu-22.04
+    steps:
+    - uses: actions/checkout@v3
+    - run: |
+        dev/check-license
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9fe7271
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+# 
+#   http://www.apache.org/licenses/LICENSE-2.0
+# 
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+.DS_Store
+.cache
+tmp/
+
+.vscode
+
+# Binaries from programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# rat check
+build/
+lib/
+
+# Test binary, build with `go test -c`
+*.test
+
+# Output of the go coverage tool
+*.out
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..d1bf3ab
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,26 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+---
+files: ^go/
+
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.4.0
+    hooks:
+      - id: golangci-lint
+        entry: bash -c 'cd iceberg && golangci-lint run --fix --timeout 5m'
+        types_or: [go]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..515fd54
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,315 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+--------------------------------------------------------------------------------
+
+This product includes a gradle wrapper.
+
+* gradlew and gradle/wrapper/gradle-wrapper.properties
+
+Copyright: 2010-2019 Gradle Authors.
+Home page: https://github.com/gradle/gradle
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Apache Avro.
+
+* Conversion in DecimalWriter is based on Avro's Conversions.DecimalConversion.
+
+Copyright: 2014-2017 The Apache Software Foundation.
+Home page: https://avro.apache.org/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Apache Parquet.
+
+* DynMethods.java
+* DynConstructors.java
+* AssertHelpers.java
+* IOUtil.java readFully and tests
+* ByteBufferInputStream implementations and tests
+
+Copyright: 2014-2017 The Apache Software Foundation.
+Home page: https://parquet.apache.org/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Cloudera Kite.
+
+* SchemaVisitor and visit methods
+
+Copyright: 2013-2017 Cloudera Inc.
+Home page: https://kitesdk.org/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Presto.
+
+* Retry wait and jitter logic in Tasks.java
+* S3FileIO logic derived from PrestoS3FileSystem.java in S3InputStream.java
+  and S3OutputStream.java
+* SQL grammar rules for parsing CALL statements in IcebergSqlExtensions.g4
+* some aspects of handling stored procedures
+
+Copyright: 2016 Facebook and contributors
+Home page: https://prestodb.io/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Apache iBATIS.
+
+* Hive ScriptRunner.java
+
+Copyright: 2004 Clinton Begin
+Home page: https://ibatis.apache.org/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Apache Hive.
+
+* Hive metastore derby schema in hive-schema-3.1.0.derby.sql
+
+Copyright: 2011-2018 The Apache Software Foundation
+Home page: https://hive.apache.org/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Apache Spark.
+
+* dev/check-license script
+* vectorized reading of definition levels in BaseVectorizedParquetValuesReader.java
+* portions of the extensions parser
+* casting logic in AssignmentAlignmentSupport
+* implementation of SetAccumulator.
+* Connector expressions.
+
+Copyright: 2011-2018 The Apache Software Foundation
+Home page: https://spark.apache.org/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Delta Lake.
+
+* AssignmentAlignmentSupport is an independent development but UpdateExpressionsSupport in Delta was used as a reference.
+
+Copyright: 2020 The Delta Lake Project Authors.
+Home page: https://delta.io/
+License: https://www.apache.org/licenses/LICENSE-2.0
+
+--------------------------------------------------------------------------------
+
+This product includes code from Apache Commons.
+
+* Core ArrayUtil.
+
+Copyright: 2020 The Apache Software Foundation
+Home page: https://commons.apache.org/
+License: https://www.apache.org/licenses/LICENSE-2.0
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..48df82e
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,8 @@
+
+Apache Iceberg
+Copyright 2023 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+--------------------------------------------------------------------------------
diff --git a/README.md b/README.md
index 5f12f79..2ad172e 100644
--- a/README.md
+++ b/README.md
@@ -1,1 +1,26 @@
-# iceberg-go
+<!--
+ - Licensed to the Apache Software Foundation (ASF) under one or more
+ - contributor license agreements.  See the NOTICE file distributed with
+ - this work for additional information regarding copyright ownership.
+ - The ASF licenses this file to You under the Apache License, Version 2.0
+ - (the "License"); you may not use this file except in compliance with
+ - the License.  You may obtain a copy of the License at
+ -
+ -   http://www.apache.org/licenses/LICENSE-2.0
+ -
+ - Unless required by applicable law or agreed to in writing, software
+ - distributed under the License is distributed on an "AS IS" BASIS,
+ - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ - See the License for the specific language governing permissions and
+ - limitations under the License.
+ -->
+
+# Iceberg Golang
+
+[![Go Reference](https://pkg.go.dev/badge/github.com/apache/iceberg-go.svg)](https://pkg.go.dev/github.com/apache/iceberg-go)
+
+`iceberg` is a Golang implementation of the [Iceberg table spec](https://iceberg.apache.org/spec/).
+
+# Get in Touch
+
+- [Iceberg community](https://iceberg.apache.org/community/)
\ No newline at end of file
diff --git a/dev/.rat-excludes b/dev/.rat-excludes
new file mode 100644
index 0000000..13d4c64
--- /dev/null
+++ b/dev/.rat-excludes
@@ -0,0 +1,5 @@
+.gitignore
+.rat-excludes
+LICENSE
+NOTICE
+go.sum
\ No newline at end of file
diff --git a/dev/check-license b/dev/check-license
new file mode 100755
index 0000000..23d22f7
--- /dev/null
+++ b/dev/check-license
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+acquire_rat_jar () {
+
+  URL="https://repo.maven.apache.org/maven2/org/apache/rat/apache-rat/${RAT_VERSION}/apache-rat-${RAT_VERSION}.jar"
+
+  JAR="$rat_jar"
+
+  # Download rat launch jar if it hasn't been downloaded yet
+  if [ ! -f "$JAR" ]; then
+    # Download
+    printf "Attempting to fetch rat\n"
+    JAR_DL="${JAR}.part"
+    if [ $(command -v curl) ]; then
+      curl -L --silent "${URL}" > "$JAR_DL" && mv "$JAR_DL" "$JAR"
+    elif [ $(command -v wget) ]; then
+      wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR"
+    else
+      printf "You do not have curl or wget installed, please install rat manually.\n"
+      exit -1
+    fi
+  fi
+
+  unzip -tq "$JAR" &> /dev/null
+  if [ $? -ne 0 ]; then 
+    # We failed to download
+    rm "$JAR"
+    printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n"
+    exit -1
+  fi
+}
+
+# Go to the Spark project root directory
+FWDIR="$(cd "`dirname "$0"`"/..; pwd)"
+cd "$FWDIR"
+
+if test -x "$JAVA_HOME/bin/java"; then
+    declare java_cmd="$JAVA_HOME/bin/java"
+else
+    declare java_cmd=java
+fi
+
+export RAT_VERSION=0.15
+export rat_jar="$FWDIR"/lib/apache-rat-${RAT_VERSION}.jar
+mkdir -p "$FWDIR"/lib
+
+[[ -f "$rat_jar" ]] || acquire_rat_jar || {
+    echo "Download failed. Obtain the rat jar manually and place it at $rat_jar"
+    exit 1
+}
+
+mkdir -p build
+$java_cmd -jar "$rat_jar" -E "$FWDIR"/dev/.rat-excludes -d "$FWDIR" > build/rat-results.txt
+
+if [ $? -ne 0 ]; then
+   echo "RAT exited abnormally"
+   exit 1
+fi
+
+ERRORS="$(cat build/rat-results.txt | grep -e "??")"
+
+if test ! -z "$ERRORS"; then 
+    echo "Could not find Apache license headers in the following files:"
+    echo "$ERRORS"
+    exit 1
+else 
+    echo -e "RAT checks passed."
+fi
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..801f5c5
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,27 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iceberg
+
+import "errors"
+
+var (
+	ErrInvalidTypeString = errors.New("invalid type")
+	ErrNotImplemented    = errors.New("not implemented")
+	ErrInvalidArgument   = errors.New("invalid argument")
+	ErrInvalidSchema     = errors.New("invalid schema")
+)
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..030d65e
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,31 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+module github.com/apache/iceberg-go
+
+go 1.20
+
+require (
+	github.com/stretchr/testify v1.8.4
+	golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..172fe17
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb h1:mIKbk8weKhSeLH2GmUTrvx8CjkyJmnU1wFmg59CUjFA=
+golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/schema.go b/schema.go
new file mode 100644
index 0000000..e9e559b
--- /dev/null
+++ b/schema.go
@@ -0,0 +1,860 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iceberg
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"sync/atomic"
+
+	"golang.org/x/exp/maps"
+	"golang.org/x/exp/slices"
+)
+
+// Schema is an Iceberg table schema, represented as a struct with
+// multiple fields. The fields are only exported via accessor methods
+// rather than exposing the slice directly in order to ensure a schema
+// as immutable.
+type Schema struct {
+	ID                 int   `json:"schema-id"`
+	IdentifierFieldIDs []int `json:"identifier-field-ids"`
+
+	fields []NestedField
+
+	// the following maps are lazily populated as needed.
+	// rather than have lock contention with a mutex, we can use
+	// atomic pointers to Store/Load the values.
+	idToName      atomic.Pointer[map[int]string]
+	idToField     atomic.Pointer[map[int]NestedField]
+	nameToID      atomic.Pointer[map[string]int]
+	nameToIDLower atomic.Pointer[map[string]int]
+}
+
+// NewSchema constructs a new schema with the provided ID
+// and list of fields.
+func NewSchema(id int, fields ...NestedField) *Schema {
+	return NewSchemaWithIdentifiers(id, []int{}, fields...)
+}
+
+// NewSchemaWithIdentifiers constructs a new schema with the provided ID
+// and fields, along with a slice of field IDs to be listed as identifier
+// fields.
+func NewSchemaWithIdentifiers(id int, identifierIDs []int, fields ...NestedField) *Schema {
+	return &Schema{ID: id, fields: fields, IdentifierFieldIDs: identifierIDs}
+}
+
+func (s *Schema) String() string {
+	var b strings.Builder
+	b.WriteString("table {")
+	for _, f := range s.fields {
+		b.WriteString("\n\t")
+		b.WriteString(f.String())
+	}
+	b.WriteString("\n}")
+	return b.String()
+}
+
+func (s *Schema) lazyNameToID() (map[string]int, error) {
+	index := s.nameToID.Load()
+	if index != nil {
+		return *index, nil
+	}
+
+	idx, err := IndexByName(s)
+	if err != nil {
+		return nil, err
+	}
+
+	s.nameToID.Store(&idx)
+	return idx, nil
+}
+
+func (s *Schema) lazyIDToField() (map[int]NestedField, error) {
+	index := s.idToField.Load()
+	if index != nil {
+		return *index, nil
+	}
+
+	idx, err := IndexByID(s)
+	if err != nil {
+		return nil, err
+	}
+
+	s.idToField.Store(&idx)
+	return idx, nil
+}
+
+func (s *Schema) lazyIDToName() (map[int]string, error) {
+	index := s.idToName.Load()
+	if index != nil {
+		return *index, nil
+	}
+
+	idx, err := IndexNameByID(s)
+	if err != nil {
+		return nil, err
+	}
+
+	s.idToName.Store(&idx)
+	return idx, nil
+}
+
+func (s *Schema) lazyNameToIDLower() (map[string]int, error) {
+	index := s.nameToIDLower.Load()
+	if index != nil {
+		return *index, nil
+	}
+
+	idx, err := s.lazyNameToID()
+	if err != nil {
+		return nil, err
+	}
+
+	out := make(map[string]int)
+	for k, v := range idx {
+		out[strings.ToLower(k)] = v
+	}
+
+	s.nameToIDLower.Store(&out)
+	return out, nil
+}
+
+func (s *Schema) Type() string { return "struct" }
+
+// AsStruct returns a Struct with the same fields as the schema which can
+// then be used as a Type.
+func (s *Schema) AsStruct() StructType    { return StructType{FieldList: s.fields} }
+func (s *Schema) NumFields() int          { return len(s.fields) }
+func (s *Schema) Field(i int) NestedField { return s.fields[i] }
+func (s *Schema) Fields() []NestedField   { return slices.Clone(s.fields) }
+
+func (s *Schema) UnmarshalJSON(b []byte) error {
+	type Alias Schema
+	aux := struct {
+		Fields []NestedField `json:"fields"`
+		*Alias
+	}{Alias: (*Alias)(s)}
+
+	if err := json.Unmarshal(b, &aux); err != nil {
+		return err
+	}
+
+	s.fields = aux.Fields
+	if s.IdentifierFieldIDs == nil {
+		s.IdentifierFieldIDs = []int{}
+	}
+	return nil
+}
+
+func (s *Schema) MarshalJSON() ([]byte, error) {
+	if s.IdentifierFieldIDs == nil {
+		s.IdentifierFieldIDs = []int{}
+	}
+
+	type Alias Schema
+	return json.Marshal(struct {
+		Type   string        `json:"type"`
+		Fields []NestedField `json:"fields"`
+		*Alias
+	}{Type: "struct", Fields: s.fields, Alias: (*Alias)(s)})
+}
+
+// FindColumnName returns the name of the column identified by the
+// passed in field id. The second return value reports whether or
+// not the field id was found in the schema.
+func (s *Schema) FindColumnName(fieldID int) (string, bool) {
+	idx, _ := s.lazyIDToName()
+	col, ok := idx[fieldID]
+	return col, ok
+}
+
+// FindFieldByName returns the field identified by the name given,
+// the second return value will be false if no field by this name
+// is found.
+//
+// Note: This search is done in a case sensitive manner. To perform
+// a case insensitive search, use [*Schema.FindFieldByNameCaseInsensitive].
+func (s *Schema) FindFieldByName(name string) (NestedField, bool) {
+	idx, _ := s.lazyNameToID()
+
+	id, ok := idx[name]
+	if !ok {
+		return NestedField{}, false
+	}
+
+	return s.FindFieldByID(id)
+}
+
+// FindFieldByNameCaseInsensitive is like [*Schema.FindFieldByName],
+// but performs a case insensitive search.
+func (s *Schema) FindFieldByNameCaseInsensitive(name string) (NestedField, bool) {
+	idx, _ := s.lazyNameToIDLower()
+
+	id, ok := idx[strings.ToLower(name)]
+	if !ok {
+		return NestedField{}, false
+	}
+
+	return s.FindFieldByID(id)
+}
+
+// FindFieldByID is like [*Schema.FindColumnByName], but returns the whole
+// field rather than just the field name.
+func (s *Schema) FindFieldByID(id int) (NestedField, bool) {
+	idx, _ := s.lazyIDToField()
+	f, ok := idx[id]
+	return f, ok
+}
+
+// FindTypeByID is like [*Schema.FindFieldByID], but returns only the data
+// type of the field.
+func (s *Schema) FindTypeByID(id int) (Type, bool) {
+	f, ok := s.FindFieldByID(id)
+	if !ok {
+		return nil, false
+	}
+
+	return f.Type, true
+}
+
+// FindTypeByName is a convenience function for calling [*Schema.FindFieldByName],
+// and then returning just the type.
+func (s *Schema) FindTypeByName(name string) (Type, bool) {
+	f, ok := s.FindFieldByName(name)
+	if !ok {
+		return nil, false
+	}
+
+	return f.Type, true
+}
+
+// FindTypeByNameCaseInsensitive is like [*Schema.FindTypeByName] but
+// performs a case insensitive search.
+func (s *Schema) FindTypeByNameCaseInsensitive(name string) (Type, bool) {
+	f, ok := s.FindFieldByNameCaseInsensitive(name)
+	if !ok {
+		return nil, false
+	}
+
+	return f.Type, true
+}
+
+// Equals compares the fields and identifierIDs, but does not compare
+// the schema ID itself.
+func (s *Schema) Equals(other *Schema) bool {
+	if other == nil {
+		return false
+	}
+
+	if s == other {
+		return true
+	}
+
+	if len(s.fields) != len(other.fields) {
+		return false
+	}
+
+	if !slices.Equal(s.IdentifierFieldIDs, other.IdentifierFieldIDs) {
+		return false
+	}
+
+	return slices.EqualFunc(s.fields, other.fields, func(a, b NestedField) bool {
+		return a.Equals(b)
+	})
+}
+
+// HighestFieldID returns the value of the numerically highest field ID
+// in this schema.
+func (s *Schema) HighestFieldID() int {
+	id, _ := Visit[int](s, findLastFieldID{})
+	return id
+}
+
+type Void = struct{}
+
+var void = Void{}
+
+// Select creates a new schema with just the fields identified by name
+// passed in the order they are provided. If caseSensitive is false,
+// then fields will be identified by case insensitive search.
+//
+// An error is returned if a requested name cannot be found.
+func (s *Schema) Select(caseSensitive bool, names ...string) (*Schema, error) {
+	ids := make(map[int]Void)
+	if caseSensitive {
+		nameMap, _ := s.lazyNameToID()
+		for _, n := range names {
+			id, ok := nameMap[n]
+			if !ok {
+				return nil, fmt.Errorf("%w: could not find column %s", ErrInvalidSchema, n)
+			}
+			ids[id] = void
+		}
+	} else {
+		nameMap, _ := s.lazyNameToIDLower()
+		for _, n := range names {
+			id, ok := nameMap[strings.ToLower(n)]
+			if !ok {
+				return nil, fmt.Errorf("%w: could not find column %s", ErrInvalidSchema, n)
+			}
+			ids[id] = void
+		}
+	}
+
+	return PruneColumns(s, ids, true)
+}
+
+// SchemaVisitor is an interface that can be implemented to allow for
+// easy traversal and processing of a schema.
+//
+// A SchemaVisitor can also optionally implement the Before/After Field,
+// ListElement, MapKey, or MapValue interfaces to allow them to get called
+// at the appropriate points within schema traversal.
+type SchemaVisitor[T any] interface {
+	Schema(schema *Schema, structResult T) T
+	Struct(st StructType, fieldResults []T) T
+	Field(field NestedField, fieldResult T) T
+	List(list ListType, elemResult T) T
+	Map(mapType MapType, keyResult, valueResult T) T
+	Primitive(p PrimitiveType) T
+}
+
+type BeforeFieldVisitor interface {
+	BeforeField(field NestedField)
+}
+
+type AfterFieldVisitor interface {
+	AfterField(field NestedField)
+}
+
+type BeforeListElementVisitor interface {
+	BeforeListElement(elem NestedField)
+}
+
+type AfterListElementVisitor interface {
+	AfterListElement(elem NestedField)
+}
+
+type BeforeMapKeyVisitor interface {
+	BeforeMapKey(key NestedField)
+}
+
+type AfterMapKeyVisitor interface {
+	AfterMapKey(key NestedField)
+}
+
+type BeforeMapValueVisitor interface {
+	BeforeMapValue(value NestedField)
+}
+
+type AfterMapValueVisitor interface {
+	AfterMapValue(value NestedField)
+}
+
+// Visit accepts a visitor and performs a post-order traversal of the given schema.
+func Visit[T any](sc *Schema, visitor SchemaVisitor[T]) (res T, err error) {
+	if sc == nil {
+		err = fmt.Errorf("%w: cannot visit nil schema", ErrInvalidArgument)
+		return
+	}
+
+	defer func() {
+		if r := recover(); r != nil {
+			switch e := r.(type) {
+			case string:
+				err = fmt.Errorf("error encountered during schema visitor: %s", e)
+			case error:
+				err = fmt.Errorf("error encountered during schema visitor: %w", e)
+			}
+		}
+	}()
+
+	return visitor.Schema(sc, visitStruct(sc.AsStruct(), visitor)), nil
+}
+
+func visitStruct[T any](obj StructType, visitor SchemaVisitor[T]) T {
+	results := make([]T, len(obj.FieldList))
+
+	bf, _ := visitor.(BeforeFieldVisitor)
+	af, _ := visitor.(AfterFieldVisitor)
+
+	for i, f := range obj.FieldList {
+		if bf != nil {
+			bf.BeforeField(f)
+		}
+
+		res := visitField(f, visitor)
+
+		if af != nil {
+			af.AfterField(f)
+		}
+
+		results[i] = visitor.Field(f, res)
+	}
+
+	return visitor.Struct(obj, results)
+}
+
+func visitList[T any](obj ListType, visitor SchemaVisitor[T]) T {
+	elemField := obj.ElementField()
+
+	if bl, ok := visitor.(BeforeListElementVisitor); ok {
+		bl.BeforeListElement(elemField)
+	} else if bf, ok := visitor.(BeforeFieldVisitor); ok {
+		bf.BeforeField(elemField)
+	}
+
+	res := visitField(elemField, visitor)
+
+	if al, ok := visitor.(AfterListElementVisitor); ok {
+		al.AfterListElement(elemField)
+	} else if af, ok := visitor.(AfterFieldVisitor); ok {
+		af.AfterField(elemField)
+	}
+
+	return visitor.List(obj, res)
+}
+
+func visitMap[T any](obj MapType, visitor SchemaVisitor[T]) T {
+	keyField, valueField := obj.KeyField(), obj.ValueField()
+
+	if bmk, ok := visitor.(BeforeMapKeyVisitor); ok {
+		bmk.BeforeMapKey(keyField)
+	} else if bf, ok := visitor.(BeforeFieldVisitor); ok {
+		bf.BeforeField(keyField)
+	}
+
+	keyRes := visitField(keyField, visitor)
+
+	if amk, ok := visitor.(AfterMapKeyVisitor); ok {
+		amk.AfterMapKey(keyField)
+	} else if af, ok := visitor.(AfterFieldVisitor); ok {
+		af.AfterField(keyField)
+	}
+
+	if bmk, ok := visitor.(BeforeMapValueVisitor); ok {
+		bmk.BeforeMapValue(valueField)
+	} else if bf, ok := visitor.(BeforeFieldVisitor); ok {
+		bf.BeforeField(valueField)
+	}
+
+	valueRes := visitField(valueField, visitor)
+
+	if amk, ok := visitor.(AfterMapValueVisitor); ok {
+		amk.AfterMapValue(valueField)
+	} else if af, ok := visitor.(AfterFieldVisitor); ok {
+		af.AfterField(valueField)
+	}
+
+	return visitor.Map(obj, keyRes, valueRes)
+}
+
+func visitField[T any](f NestedField, visitor SchemaVisitor[T]) T {
+	switch typ := f.Type.(type) {
+	case *StructType:
+		return visitStruct(*typ, visitor)
+	case *ListType:
+		return visitList(*typ, visitor)
+	case *MapType:
+		return visitMap(*typ, visitor)
+	default: // primitive
+		return visitor.Primitive(typ.(PrimitiveType))
+	}
+}
+
+// IndexByID performs a post-order traversal of the given schema and
+// returns a mapping from field ID to field.
+func IndexByID(schema *Schema) (map[int]NestedField, error) {
+	return Visit[map[int]NestedField](schema, &indexByID{index: make(map[int]NestedField)})
+}
+
+type indexByID struct {
+	index map[int]NestedField
+}
+
+func (i *indexByID) Schema(*Schema, map[int]NestedField) map[int]NestedField {
+	return i.index
+}
+
+func (i *indexByID) Struct(StructType, []map[int]NestedField) map[int]NestedField {
+	return i.index
+}
+
+func (i *indexByID) Field(field NestedField, _ map[int]NestedField) map[int]NestedField {
+	i.index[field.ID] = field
+	return i.index
+}
+
+func (i *indexByID) List(list ListType, _ map[int]NestedField) map[int]NestedField {
+	i.index[list.ElementID] = list.ElementField()
+	return i.index
+}
+
+func (i *indexByID) Map(mapType MapType, _, _ map[int]NestedField) map[int]NestedField {
+	i.index[mapType.KeyID] = mapType.KeyField()
+	i.index[mapType.ValueID] = mapType.ValueField()
+	return i.index
+}
+
+func (i *indexByID) Primitive(PrimitiveType) map[int]NestedField {
+	return i.index
+}
+
+// IndexByName performs a post-order traversal of the schema and returns
+// a mapping from field name to field ID.
+func IndexByName(schema *Schema) (map[string]int, error) {
+	if schema == nil {
+		return nil, fmt.Errorf("%w: cannot index nil schema", ErrInvalidArgument)
+	}
+
+	if len(schema.fields) > 0 {
+		indexer := &indexByName{
+			index:           make(map[string]int),
+			shortNameId:     make(map[string]int),
+			fieldNames:      make([]string, 0),
+			shortFieldNames: make([]string, 0),
+		}
+		if _, err := Visit[map[string]int](schema, indexer); err != nil {
+			return nil, err
+		}
+
+		return indexer.ByName(), nil
+	}
+	return map[string]int{}, nil
+}
+
+// IndexNameByID performs a post-order traversal of the schema and returns
+// a mapping from field ID to field name.
+func IndexNameByID(schema *Schema) (map[int]string, error) {
+	indexer := &indexByName{
+		index:           make(map[string]int),
+		shortNameId:     make(map[string]int),
+		fieldNames:      make([]string, 0),
+		shortFieldNames: make([]string, 0),
+	}
+	if _, err := Visit[map[string]int](schema, indexer); err != nil {
+		return nil, err
+	}
+	return indexer.ByID(), nil
+}
+
+type indexByName struct {
+	index           map[string]int
+	shortNameId     map[string]int
+	combinedIndex   map[string]int
+	fieldNames      []string
+	shortFieldNames []string
+}
+
+func (i *indexByName) ByID() map[int]string {
+	idToName := make(map[int]string)
+	for k, v := range i.index {
+		idToName[v] = k
+	}
+	return idToName
+}
+
+func (i *indexByName) ByName() map[string]int {
+	i.combinedIndex = maps.Clone(i.shortNameId)
+	maps.Copy(i.combinedIndex, i.index)
+	return i.combinedIndex
+}
+
+func (i *indexByName) Primitive(PrimitiveType) map[string]int { return i.index }
+func (i *indexByName) addField(name string, fieldID int) {
+	fullName := name
+	if len(i.fieldNames) > 0 {
+		fullName = strings.Join(i.fieldNames, ".") + "." + name
+	}
+
+	if _, ok := i.index[fullName]; ok {
+		panic(fmt.Errorf("%w: multiple fields for name %s: %d and %d",
+			ErrInvalidSchema, fullName, i.index[fullName], fieldID))
+	}
+
+	i.index[fullName] = fieldID
+	if len(i.shortFieldNames) > 0 {
+		shortName := strings.Join(i.shortFieldNames, ".") + "." + name
+		i.shortNameId[shortName] = fieldID
+	}
+}
+
+func (i *indexByName) Schema(*Schema, map[string]int) map[string]int {
+	return i.index
+}
+
+func (i *indexByName) Struct(StructType, []map[string]int) map[string]int {
+	return i.index
+}
+
+func (i *indexByName) Field(field NestedField, _ map[string]int) map[string]int {
+	i.addField(field.Name, field.ID)
+	return i.index
+}
+
+func (i *indexByName) List(list ListType, _ map[string]int) map[string]int {
+	i.addField(list.ElementField().Name, list.ElementID)
+	return i.index
+}
+
+func (i *indexByName) Map(mapType MapType, _, _ map[string]int) map[string]int {
+	i.addField(mapType.KeyField().Name, mapType.KeyID)
+	i.addField(mapType.ValueField().Name, mapType.ValueID)
+	return i.index
+}
+
+func (i *indexByName) BeforeListElement(elem NestedField) {
+	if _, ok := elem.Type.(*StructType); !ok {
+		i.shortFieldNames = append(i.shortFieldNames, elem.Name)
+	}
+	i.fieldNames = append(i.fieldNames, elem.Name)
+}
+
+func (i *indexByName) AfterListElement(elem NestedField) {
+	if _, ok := elem.Type.(*StructType); !ok {
+		i.shortFieldNames = i.shortFieldNames[:len(i.shortFieldNames)-1]
+	}
+	i.fieldNames = i.fieldNames[:len(i.fieldNames)-1]
+}
+
+func (i *indexByName) BeforeField(field NestedField) {
+	i.fieldNames = append(i.fieldNames, field.Name)
+	i.shortFieldNames = append(i.shortFieldNames, field.Name)
+}
+
+func (i *indexByName) AfterField(field NestedField) {
+	i.fieldNames = i.fieldNames[:len(i.fieldNames)-1]
+	i.shortFieldNames = i.shortFieldNames[:len(i.shortFieldNames)-1]
+}
+
+// PruneColumns visits a schema pruning any columns which do not exist in the
+// provided selected set. Parent fields of a selected child will be retained.
+func PruneColumns(schema *Schema, selected map[int]Void, selectFullTypes bool) (*Schema, error) {
+
+	result, err := Visit[Type](schema, &pruneColVisitor{selected: selected,
+		fullTypes: selectFullTypes})
+	if err != nil {
+		return nil, err
+	}
+
+	n, ok := result.(NestedType)
+	if !ok {
+		n = &StructType{}
+	}
+
+	newIdentifierIDs := make([]int, 0, len(schema.IdentifierFieldIDs))
+	for _, id := range schema.IdentifierFieldIDs {
+		if _, ok := selected[id]; ok {
+			newIdentifierIDs = append(newIdentifierIDs, id)
+		}
+	}
+
+	return &Schema{
+		fields:             n.Fields(),
+		ID:                 schema.ID,
+		IdentifierFieldIDs: newIdentifierIDs,
+	}, nil
+}
+
+type pruneColVisitor struct {
+	selected  map[int]Void
+	fullTypes bool
+}
+
+func (p *pruneColVisitor) Schema(_ *Schema, structResult Type) Type {
+	return structResult
+}
+
+func (p *pruneColVisitor) Struct(st StructType, fieldResults []Type) Type {
+	selected, fields := []NestedField{}, st.FieldList
+	sameType := true
+
+	for i, t := range fieldResults {
+		field := fields[i]
+		if field.Type == t {
+			selected = append(selected, field)
+		} else if t != nil {
+			sameType = false
+			// type has changed, create a new field with the projected type
+			selected = append(selected, NestedField{
+				ID:       field.ID,
+				Name:     field.Name,
+				Type:     t,
+				Doc:      field.Doc,
+				Required: field.Required,
+			})
+		}
+	}
+
+	if len(selected) > 0 {
+		if len(selected) == len(fields) && sameType {
+			// nothing changed, return the original
+			return &st
+		} else {
+			return &StructType{FieldList: selected}
+		}
+	}
+
+	return nil
+}
+
+func (p *pruneColVisitor) Field(field NestedField, fieldResult Type) Type {
+	_, ok := p.selected[field.ID]
+	if !ok {
+		if fieldResult != nil {
+			return fieldResult
+		}
+
+		return nil
+	}
+
+	if p.fullTypes {
+		return field.Type
+	}
+
+	if _, ok := field.Type.(*StructType); ok {
+		return p.projectSelectedStruct(fieldResult)
+	}
+
+	typ, ok := field.Type.(PrimitiveType)
+	if !ok {
+		panic(fmt.Errorf("%w: cannot explicitly project List or Map types, %d:%s of type %s was selected",
+			ErrInvalidSchema, field.ID, field.Name, field.Type))
+	}
+	return typ
+}
+
+func (p *pruneColVisitor) List(list ListType, elemResult Type) Type {
+	_, ok := p.selected[list.ElementID]
+	if !ok {
+		if elemResult != nil {
+			return p.projectList(&list, elemResult)
+		}
+
+		return nil
+	}
+
+	if p.fullTypes {
+		return &list
+	}
+
+	_, ok = list.Element.(*StructType)
+	if list.Element != nil && ok {
+		projected := p.projectSelectedStruct(elemResult)
+		return p.projectList(&list, projected)
+	}
+
+	if _, ok = list.Element.(PrimitiveType); !ok {
+		panic(fmt.Errorf("%w: cannot explicitly project List or Map types, %d of type %s was selected",
+			ErrInvalidSchema, list.ElementID, list.Element))
+	}
+
+	return &list
+}
+
+func (p *pruneColVisitor) Map(mapType MapType, keyResult, valueResult Type) Type {
+	_, ok := p.selected[mapType.ValueID]
+	if !ok {
+		if valueResult != nil {
+			return p.projectMap(&mapType, valueResult)
+		}
+
+		if _, ok = p.selected[mapType.KeyID]; ok {
+			return &mapType
+		}
+
+		return nil
+	}
+
+	if p.fullTypes {
+		return &mapType
+	}
+
+	_, ok = mapType.ValueType.(*StructType)
+	if mapType.ValueType != nil && ok {
+		projected := p.projectSelectedStruct(valueResult)
+		return p.projectMap(&mapType, projected)
+	}
+
+	if _, ok = mapType.ValueType.(PrimitiveType); !ok {
+		panic(fmt.Errorf("%w: cannot explicitly project List or Map types, Map value %d of type %s was selected",
+			ErrInvalidSchema, mapType.ValueID, mapType.ValueType))
+	}
+
+	return &mapType
+}
+
+func (p *pruneColVisitor) Primitive(_ PrimitiveType) Type { return nil }
+
+func (*pruneColVisitor) projectSelectedStruct(projected Type) *StructType {
+	if projected == nil {
+		return &StructType{}
+	}
+
+	if ty, ok := projected.(*StructType); ok {
+		return ty
+	}
+
+	panic("expected a struct")
+}
+
+func (*pruneColVisitor) projectList(listType *ListType, elementResult Type) *ListType {
+	if listType.Element.Equals(elementResult) {
+		return listType
+	}
+
+	return &ListType{ElementID: listType.ElementID, Element: elementResult,
+		ElementRequired: listType.ElementRequired}
+}
+
+func (*pruneColVisitor) projectMap(mapType *MapType, valueResult Type) *MapType {
+	if mapType.ValueType.Equals(valueResult) {
+		return mapType
+	}
+
+	return &MapType{
+		KeyID:         mapType.KeyID,
+		ValueID:       mapType.ValueID,
+		KeyType:       mapType.KeyType,
+		ValueType:     valueResult,
+		ValueRequired: mapType.ValueRequired,
+	}
+}
+
+type findLastFieldID struct{}
+
+func (findLastFieldID) Schema(_ *Schema, result int) int {
+	return result
+}
+
+func (findLastFieldID) Struct(_ StructType, fieldResults []int) int {
+	return max(fieldResults...)
+}
+
+func (findLastFieldID) Field(field NestedField, fieldResult int) int {
+	return max(field.ID, fieldResult)
+}
+
+func (findLastFieldID) List(_ ListType, elemResult int) int { return elemResult }
+
+func (findLastFieldID) Map(_ MapType, keyResult, valueResult int) int {
+	return max(keyResult, valueResult)
+}
+
+func (findLastFieldID) Primitive(PrimitiveType) int { return 0 }
diff --git a/types.go b/types.go
new file mode 100644
index 0000000..8ca0ad7
--- /dev/null
+++ b/types.go
@@ -0,0 +1,615 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iceberg
+
+import (
+	"encoding/json"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"golang.org/x/exp/slices"
+)
+
+var (
+	regexFromBrackets = regexp.MustCompile(`^\w+\[(\d+)\]$`)
+	decimalRegex      = regexp.MustCompile(`decimal\(\s*(\d+)\s*,\s*(\d+)\s*\)`)
+)
+
+type Properties map[string]string
+
+// Type is an interface representing any of the available iceberg types,
+// such as primitives (int32/int64/etc.) or nested types (list/struct/map).
+type Type interface {
+	fmt.Stringer
+	Type() string
+	Equals(Type) bool
+}
+
+// NestedType is an interface that allows access to the child fields of
+// a nested type such as a list/struct/map type.
+type NestedType interface {
+	Type
+	Fields() []NestedField
+}
+
+type typeIFace struct {
+	Type
+}
+
+func (t *typeIFace) MarshalJSON() ([]byte, error) {
+	if nested, ok := t.Type.(NestedType); ok {
+		return json.Marshal(nested)
+	}
+	return []byte(`"` + t.Type.Type() + `"`), nil
+}
+
+func (t *typeIFace) UnmarshalJSON(b []byte) error {
+	var typename string
+	err := json.Unmarshal(b, &typename)
+	if err == nil {
+		switch typename {
+		case "boolean":
+			t.Type = BooleanType{}
+		case "int":
+			t.Type = Int32Type{}
+		case "long":
+			t.Type = Int64Type{}
+		case "float":
+			t.Type = Float32Type{}
+		case "double":
+			t.Type = Float64Type{}
+		case "date":
+			t.Type = DateType{}
+		case "time":
+			t.Type = TimeType{}
+		case "timestamp":
+			t.Type = TimestampType{}
+		case "timestamptz":
+			t.Type = TimestampTzType{}
+		case "string":
+			t.Type = StringType{}
+		case "uuid":
+			t.Type = UUIDType{}
+		case "binary":
+			t.Type = BinaryType{}
+		default:
+			switch {
+			case strings.HasPrefix(typename, "fixed"):
+				matches := regexFromBrackets.FindStringSubmatch(typename)
+				if len(matches) != 2 {
+					return fmt.Errorf("%w: %s", ErrInvalidTypeString, typename)
+				}
+
+				n, _ := strconv.Atoi(matches[1])
+				t.Type = FixedType{len: n}
+			case strings.HasPrefix(typename, "decimal"):
+				matches := decimalRegex.FindStringSubmatch(typename)
+				if len(matches) != 3 {
+					return fmt.Errorf("%w: %s", ErrInvalidTypeString, typename)
+				}
+
+				prec, _ := strconv.Atoi(matches[1])
+				scale, _ := strconv.Atoi(matches[2])
+				t.Type = DecimalType{precision: prec, scale: scale}
+			default:
+				return fmt.Errorf("%w: unrecognized field type", ErrInvalidSchema)
+			}
+		}
+		return nil
+	}
+
+	aux := struct {
+		TypeName string `json:"type"`
+	}{}
+	if err = json.Unmarshal(b, &aux); err != nil {
+		return err
+	}
+
+	switch aux.TypeName {
+	case "list":
+		t.Type = &ListType{}
+	case "map":
+		t.Type = &MapType{}
+	case "struct":
+		t.Type = &StructType{}
+	default:
+		return fmt.Errorf("%w: %s", ErrInvalidTypeString, aux.TypeName)
+	}
+
+	return json.Unmarshal(b, t.Type)
+}
+
+type NestedField struct {
+	Type `json:"-"`
+
+	ID             int    `json:"id"`
+	Name           string `json:"name"`
+	Required       bool   `json:"required"`
+	Doc            string `json:"doc,omitempty"`
+	InitialDefault any    `json:"initial-default,omitempty"`
+	WriteDefault   any    `json:"write-default,omitempty"`
+}
+
+func optOrReq(required bool) string {
+	if required {
+		return "required"
+	}
+	return "optional"
+}
+
+func (n NestedField) String() string {
+	doc := n.Doc
+	if doc != "" {
+		doc = " (" + doc + ")"
+	}
+
+	return fmt.Sprintf("%d: %s: %s %s%s",
+		n.ID, n.Name, optOrReq(n.Required), n.Type, doc)
+}
+
+func (n *NestedField) Equals(other NestedField) bool {
+	return n.ID == other.ID &&
+		n.Name == other.Name &&
+		n.Required == other.Required &&
+		n.Doc == other.Doc &&
+		n.InitialDefault == other.InitialDefault &&
+		n.WriteDefault == other.WriteDefault &&
+		n.Type.Equals(other.Type)
+}
+
+func (n NestedField) MarshalJSON() ([]byte, error) {
+	type Alias NestedField
+	return json.Marshal(struct {
+		Type *typeIFace `json:"type"`
+		*Alias
+	}{Type: &typeIFace{n.Type}, Alias: (*Alias)(&n)})
+}
+
+func (n *NestedField) UnmarshalJSON(b []byte) error {
+	type Alias NestedField
+	aux := struct {
+		Type typeIFace `json:"type"`
+		*Alias
+	}{
+		Alias: (*Alias)(n),
+	}
+
+	if err := json.Unmarshal(b, &aux); err != nil {
+		return err
+	}
+
+	n.Type = aux.Type.Type
+
+	return nil
+}
+
+type StructType struct {
+	FieldList []NestedField `json:"fields"`
+}
+
+func (s *StructType) Equals(other Type) bool {
+	st, ok := other.(*StructType)
+	if !ok {
+		return false
+	}
+
+	return slices.EqualFunc(s.FieldList, st.FieldList, func(a, b NestedField) bool {
+		return a.Equals(b)
+	})
+}
+
+func (s *StructType) Fields() []NestedField { return s.FieldList }
+
+func (s *StructType) MarshalJSON() ([]byte, error) {
+	type Alias StructType
+	return json.Marshal(struct {
+		Type string `json:"type"`
+		*Alias
+	}{Type: s.Type(), Alias: (*Alias)(s)})
+}
+
+func (*StructType) Type() string { return "struct" }
+func (s *StructType) String() string {
+	var b strings.Builder
+	b.WriteString("struct<")
+	for i, f := range s.FieldList {
+		if i != 0 {
+			b.WriteString(", ")
+		}
+		fmt.Fprintf(&b, "%d: %s: ",
+			f.ID, f.Name)
+		if f.Required {
+			b.WriteString("required ")
+		}
+		b.WriteString(f.Type.String())
+		if f.Doc != "" {
+			b.WriteString(" (")
+			b.WriteString(f.Doc)
+			b.WriteByte(')')
+		}
+	}
+	b.WriteString(">")
+
+	return b.String()
+}
+
+type ListType struct {
+	ElementID       int  `json:"element-id"`
+	Element         Type `json:"-"`
+	ElementRequired bool `json:"element-required"`
+}
+
+func (l *ListType) MarshalJSON() ([]byte, error) {
+	type Alias ListType
+	return json.Marshal(struct {
+		Type string `json:"type"`
+		*Alias
+		Element *typeIFace `json:"element"`
+	}{Type: l.Type(), Alias: (*Alias)(l), Element: &typeIFace{l.Element}})
+}
+
+func (l *ListType) Equals(other Type) bool {
+	rhs, ok := other.(*ListType)
+	if !ok {
+		return false
+	}
+
+	return l.ElementID == rhs.ElementID &&
+		l.Element.Equals(rhs.Element) &&
+		l.ElementRequired == rhs.ElementRequired
+}
+
+func (l *ListType) Fields() []NestedField {
+	return []NestedField{l.ElementField()}
+}
+
+func (l *ListType) ElementField() NestedField {
+	return NestedField{
+		ID:       l.ElementID,
+		Name:     "element",
+		Type:     l.Element,
+		Required: l.ElementRequired,
+	}
+}
+
+func (*ListType) Type() string     { return "list" }
+func (l *ListType) String() string { return fmt.Sprintf("list<%s>", l.Element) }
+
+func (l *ListType) UnmarshalJSON(b []byte) error {
+	aux := struct {
+		ID   int       `json:"element-id"`
+		Elem typeIFace `json:"element"`
+		Req  bool      `json:"element-required"`
+	}{}
+	if err := json.Unmarshal(b, &aux); err != nil {
+		return err
+	}
+
+	l.ElementID = aux.ID
+	l.Element = aux.Elem.Type
+	l.ElementRequired = aux.Req
+	return nil
+}
+
+type MapType struct {
+	KeyID         int  `json:"key-id"`
+	KeyType       Type `json:"-"`
+	ValueID       int  `json:"value-id"`
+	ValueType     Type `json:"-"`
+	ValueRequired bool `json:"value-required"`
+}
+
+func (m *MapType) MarshalJSON() ([]byte, error) {
+	type Alias MapType
+	return json.Marshal(struct {
+		Type string `json:"type"`
+		*Alias
+		KeyType   *typeIFace `json:"key"`
+		ValueType *typeIFace `json:"value"`
+	}{Type: m.Type(), Alias: (*Alias)(m),
+		KeyType:   &typeIFace{m.KeyType},
+		ValueType: &typeIFace{m.ValueType}})
+}
+
+func (m *MapType) Equals(other Type) bool {
+	rhs, ok := other.(*MapType)
+	if !ok {
+		return false
+	}
+
+	return m.KeyID == rhs.KeyID &&
+		m.KeyType.Equals(rhs.KeyType) &&
+		m.ValueID == rhs.ValueID &&
+		m.ValueType.Equals(rhs.ValueType) &&
+		m.ValueRequired == rhs.ValueRequired
+}
+
+func (m *MapType) Fields() []NestedField {
+	return []NestedField{m.KeyField(), m.ValueField()}
+}
+
+func (m *MapType) KeyField() NestedField {
+	return NestedField{
+		Name:     "key",
+		ID:       m.KeyID,
+		Type:     m.KeyType,
+		Required: true,
+	}
+}
+
+func (m *MapType) ValueField() NestedField {
+	return NestedField{
+		Name:     "value",
+		ID:       m.ValueID,
+		Type:     m.ValueType,
+		Required: m.ValueRequired,
+	}
+}
+
+func (*MapType) Type() string { return "map" }
+func (m *MapType) String() string {
+	return fmt.Sprintf("map<%s, %s>", m.KeyType, m.ValueType)
+}
+
+func (m *MapType) UnmarshalJSON(b []byte) error {
+	aux := struct {
+		KeyID    int       `json:"key-id"`
+		Key      typeIFace `json:"key"`
+		ValueID  int       `json:"value-id"`
+		Value    typeIFace `json:"value"`
+		ValueReq *bool     `json:"value-required"`
+	}{}
+	if err := json.Unmarshal(b, &aux); err != nil {
+		return err
+	}
+
+	m.KeyID, m.KeyType = aux.KeyID, aux.Key.Type
+	m.ValueID, m.ValueType = aux.ValueID, aux.Value.Type
+	if aux.ValueReq == nil {
+		m.ValueRequired = true
+	} else {
+		m.ValueRequired = *aux.ValueReq
+	}
+	return nil
+}
+
+func FixedTypeOf(n int) FixedType { return FixedType{len: n} }
+
+type FixedType struct {
+	len int
+}
+
+func (f FixedType) Equals(other Type) bool {
+	rhs, ok := other.(FixedType)
+	if !ok {
+		return false
+	}
+
+	return f.len == rhs.len
+}
+func (f FixedType) Len() int       { return f.len }
+func (f FixedType) Type() string   { return fmt.Sprintf("fixed[%d]", f.len) }
+func (f FixedType) String() string { return fmt.Sprintf("fixed[%d]", f.len) }
+
+func DecimalTypeOf(prec, scale int) DecimalType {
+	return DecimalType{precision: prec, scale: scale}
+}
+
+type DecimalType struct {
+	precision, scale int
+}
+
+func (d DecimalType) Equals(other Type) bool {
+	rhs, ok := other.(DecimalType)
+	if !ok {
+		return false
+	}
+
+	return d.precision == rhs.precision &&
+		d.scale == rhs.scale
+}
+
+func (d DecimalType) Type() string   { return fmt.Sprintf("decimal(%d, %d)", d.precision, d.scale) }
+func (d DecimalType) String() string { return fmt.Sprintf("decimal(%d, %d)", d.precision, d.scale) }
+func (d DecimalType) Precision() int { return d.precision }
+func (d DecimalType) Scale() int     { return d.scale }
+
+type PrimitiveType interface {
+	Type
+	primitive()
+}
+
+type BooleanType struct{}
+
+func (BooleanType) Equals(other Type) bool {
+	_, ok := other.(BooleanType)
+	return ok
+}
+
+func (BooleanType) primitive()     {}
+func (BooleanType) Type() string   { return "boolean" }
+func (BooleanType) String() string { return "boolean" }
+
+// Int32Type is the "int"/"integer" type of the iceberg spec.
+type Int32Type struct{}
+
+func (Int32Type) Equals(other Type) bool {
+	_, ok := other.(Int32Type)
+	return ok
+}
+
+func (Int32Type) primitive()     {}
+func (Int32Type) Type() string   { return "int" }
+func (Int32Type) String() string { return "int" }
+
+// Int64Type is the "long" type of the iceberg spec.
+type Int64Type struct{}
+
+func (Int64Type) Equals(other Type) bool {
+	_, ok := other.(Int64Type)
+	return ok
+}
+
+func (Int64Type) primitive()     {}
+func (Int64Type) Type() string   { return "long" }
+func (Int64Type) String() string { return "long" }
+
+// Float32Type is the "float" type in the iceberg spec.
+type Float32Type struct{}
+
+func (Float32Type) Equals(other Type) bool {
+	_, ok := other.(Float32Type)
+	return ok
+}
+
+func (Float32Type) primitive()     {}
+func (Float32Type) Type() string   { return "float" }
+func (Float32Type) String() string { return "float" }
+
+// Float64Type represents the "double" type of the iceberg spec.
+type Float64Type struct{}
+
+func (Float64Type) Equals(other Type) bool {
+	_, ok := other.(Float64Type)
+	return ok
+}
+
+func (Float64Type) primitive()     {}
+func (Float64Type) Type() string   { return "double" }
+func (Float64Type) String() string { return "double" }
+
+type Date int32
+
+// DateType represents a calendar date without a timezone or time,
+// represented as a 32-bit integer denoting the number of days since
+// the unix epoch.
+type DateType struct{}
+
+func (DateType) Equals(other Type) bool {
+	_, ok := other.(DateType)
+	return ok
+}
+
+func (DateType) primitive()     {}
+func (DateType) Type() string   { return "date" }
+func (DateType) String() string { return "date" }
+
+type Time int64
+
+// TimeType represents a number of microseconds since midnight.
+type TimeType struct{}
+
+func (TimeType) Equals(other Type) bool {
+	_, ok := other.(TimeType)
+	return ok
+}
+
+func (TimeType) primitive()     {}
+func (TimeType) Type() string   { return "time" }
+func (TimeType) String() string { return "time" }
+
+type Timestamp int64
+
+// TimestampType represents a number of microseconds since the unix epoch
+// without regard for timezone.
+type TimestampType struct{}
+
+func (TimestampType) Equals(other Type) bool {
+	_, ok := other.(TimestampType)
+	return ok
+}
+
+func (TimestampType) primitive()     {}
+func (TimestampType) Type() string   { return "timestamp" }
+func (TimestampType) String() string { return "timestamp" }
+
+// TimestampTzType represents a timestamp stored as UTC representing the
+// number of microseconds since the unix epoch.
+type TimestampTzType struct{}
+
+func (TimestampTzType) Equals(other Type) bool {
+	_, ok := other.(TimestampTzType)
+	return ok
+}
+
+func (TimestampTzType) primitive()     {}
+func (TimestampTzType) Type() string   { return "timestamptz" }
+func (TimestampTzType) String() string { return "timestamptz" }
+
+type StringType struct{}
+
+func (StringType) Equals(other Type) bool {
+	_, ok := other.(StringType)
+	return ok
+}
+
+func (StringType) primitive()     {}
+func (StringType) Type() string   { return "string" }
+func (StringType) String() string { return "string" }
+
+type UUIDType struct{}
+
+func (UUIDType) Equals(other Type) bool {
+	_, ok := other.(UUIDType)
+	return ok
+}
+
+func (UUIDType) primitive()     {}
+func (UUIDType) Type() string   { return "uuid" }
+func (UUIDType) String() string { return "uuid" }
+
+type BinaryType struct{}
+
+func (BinaryType) Equals(other Type) bool {
+	_, ok := other.(BinaryType)
+	return ok
+}
+
+func (BinaryType) primitive()     {}
+func (BinaryType) Type() string   { return "binary" }
+func (BinaryType) String() string { return "binary" }
+
+var PrimitiveTypes = struct {
+	Bool        PrimitiveType
+	Int32       PrimitiveType
+	Int64       PrimitiveType
+	Float32     PrimitiveType
+	Float64     PrimitiveType
+	Date        PrimitiveType
+	Time        PrimitiveType
+	Timestamp   PrimitiveType
+	TimestampTz PrimitiveType
+	String      PrimitiveType
+	Binary      PrimitiveType
+	UUID        PrimitiveType
+}{
+	Bool:        BooleanType{},
+	Int32:       Int32Type{},
+	Int64:       Int64Type{},
+	Float32:     Float32Type{},
+	Float64:     Float64Type{},
+	Date:        DateType{},
+	Time:        TimeType{},
+	Timestamp:   TimestampType{},
+	TimestampTz: TimestampTzType{},
+	String:      StringType{},
+	Binary:      BinaryType{},
+	UUID:        UUIDType{},
+}
diff --git a/utils.go b/utils.go
new file mode 100644
index 0000000..ccb6bbc
--- /dev/null
+++ b/utils.go
@@ -0,0 +1,54 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iceberg
+
+import (
+	"runtime/debug"
+
+	"golang.org/x/exp/constraints"
+)
+
+var version string
+
+func init() {
+	version = "(unknown version)"
+	if info, ok := debug.ReadBuildInfo(); ok {
+		for _, dep := range info.Deps {
+			switch {
+			case dep.Path == "github.com/apache/iceberg/go/iceberg":
+				version = dep.Version
+			}
+		}
+	}
+}
+
+func Version() string { return version }
+
+func max[T constraints.Ordered](vals ...T) T {
+	if len(vals) == 0 {
+		panic("can't call max with no arguments")
+	}
+
+	out := vals[0]
+	for _, v := range vals[1:] {
+		if v > out {
+			out = v
+		}
+	}
+	return out
+}


Test patch:
diff --git a/schema_test.go b/schema_test.go
new file mode 100644
index 0000000..9190d8b
--- /dev/null
+++ b/schema_test.go
@@ -0,0 +1,758 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iceberg_test
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/apache/iceberg-go"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+var (
+	tableSchemaNested = iceberg.NewSchemaWithIdentifiers(1,
+		[]int{1},
+		iceberg.NestedField{
+			ID: 1, Name: "foo", Type: iceberg.PrimitiveTypes.String, Required: false},
+		iceberg.NestedField{
+			ID: 2, Name: "bar", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+		iceberg.NestedField{
+			ID: 3, Name: "baz", Type: iceberg.PrimitiveTypes.Bool, Required: false},
+		iceberg.NestedField{
+			ID: 4, Name: "qux", Required: true, Type: &iceberg.ListType{
+				ElementID: 5, Element: iceberg.PrimitiveTypes.String, ElementRequired: true}},
+		iceberg.NestedField{
+			ID: 6, Name: "quux",
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.String,
+				ValueID: 8,
+				ValueType: &iceberg.MapType{
+					KeyID:         9,
+					KeyType:       iceberg.PrimitiveTypes.String,
+					ValueID:       10,
+					ValueType:     iceberg.PrimitiveTypes.Int32,
+					ValueRequired: true,
+				},
+				ValueRequired: true,
+			},
+			Required: true},
+		iceberg.NestedField{
+			ID: 11, Name: "location", Type: &iceberg.ListType{
+				ElementID: 12, Element: &iceberg.StructType{
+					FieldList: []iceberg.NestedField{
+						{ID: 13, Name: "latitude", Type: iceberg.PrimitiveTypes.Float32, Required: false},
+						{ID: 14, Name: "longitude", Type: iceberg.PrimitiveTypes.Float32, Required: false},
+					},
+				},
+				ElementRequired: true},
+			Required: true},
+		iceberg.NestedField{
+			ID:   15,
+			Name: "person",
+			Type: &iceberg.StructType{
+				FieldList: []iceberg.NestedField{
+					{ID: 16, Name: "name", Type: iceberg.PrimitiveTypes.String, Required: false},
+					{ID: 17, Name: "age", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+				},
+			},
+			Required: false,
+		},
+	)
+
+	tableSchemaSimple = iceberg.NewSchemaWithIdentifiers(1,
+		[]int{2},
+		iceberg.NestedField{ID: 1, Name: "foo", Type: iceberg.PrimitiveTypes.String},
+		iceberg.NestedField{ID: 2, Name: "bar", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+		iceberg.NestedField{ID: 3, Name: "baz", Type: iceberg.PrimitiveTypes.Bool},
+	)
+)
+
+func TestSchemaToString(t *testing.T) {
+	assert.Equal(t, 3, tableSchemaSimple.NumFields())
+	assert.Equal(t, `table {
+	1: foo: optional string
+	2: bar: required int
+	3: baz: optional boolean
+}`, tableSchemaSimple.String())
+}
+
+func TestNestedFieldToString(t *testing.T) {
+	tests := []struct {
+		idx      int
+		expected string
+	}{
+		{0, "1: foo: optional string"},
+		{1, "2: bar: required int"},
+		{2, "3: baz: optional boolean"},
+		{3, "4: qux: required list<string>"},
+		{4, "6: quux: required map<string, map<string, int>>"},
+		{5, "11: location: required list<struct<13: latitude: float, 14: longitude: float>>"},
+		{6, "15: person: optional struct<16: name: string, 17: age: required int>"},
+	}
+
+	for _, tt := range tests {
+		assert.Equal(t, tt.expected, tableSchemaNested.Field(tt.idx).String())
+	}
+}
+
+func TestSchemaIndexByIDVisitor(t *testing.T) {
+	index, err := iceberg.IndexByID(tableSchemaNested)
+	require.NoError(t, err)
+
+	assert.Equal(t, map[int]iceberg.NestedField{
+		1: tableSchemaNested.Field(0),
+		2: tableSchemaNested.Field(1),
+		3: tableSchemaNested.Field(2),
+		4: tableSchemaNested.Field(3),
+		5: {ID: 5, Name: "element", Type: iceberg.PrimitiveTypes.String, Required: true},
+		6: tableSchemaNested.Field(4),
+		7: {ID: 7, Name: "key", Type: iceberg.PrimitiveTypes.String, Required: true},
+		8: {ID: 8, Name: "value", Type: &iceberg.MapType{
+			KeyID:         9,
+			KeyType:       iceberg.PrimitiveTypes.String,
+			ValueID:       10,
+			ValueType:     iceberg.PrimitiveTypes.Int32,
+			ValueRequired: true,
+		}, Required: true},
+		9:  {ID: 9, Name: "key", Type: iceberg.PrimitiveTypes.String, Required: true},
+		10: {ID: 10, Name: "value", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+		11: tableSchemaNested.Field(5),
+		12: {ID: 12, Name: "element", Type: &iceberg.StructType{
+			FieldList: []iceberg.NestedField{
+				{ID: 13, Name: "latitude", Type: iceberg.PrimitiveTypes.Float32, Required: false},
+				{ID: 14, Name: "longitude", Type: iceberg.PrimitiveTypes.Float32, Required: false},
+			},
+		}, Required: true},
+		13: {ID: 13, Name: "latitude", Type: iceberg.PrimitiveTypes.Float32, Required: false},
+		14: {ID: 14, Name: "longitude", Type: iceberg.PrimitiveTypes.Float32, Required: false},
+		15: tableSchemaNested.Field(6),
+		16: {ID: 16, Name: "name", Type: iceberg.PrimitiveTypes.String, Required: false},
+		17: {ID: 17, Name: "age", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+	}, index)
+}
+
+func TestSchemaIndexByName(t *testing.T) {
+	index, err := iceberg.IndexByName(tableSchemaNested)
+	require.NoError(t, err)
+
+	assert.Equal(t, map[string]int{
+		"foo":                        1,
+		"bar":                        2,
+		"baz":                        3,
+		"qux":                        4,
+		"qux.element":                5,
+		"quux":                       6,
+		"quux.key":                   7,
+		"quux.value":                 8,
+		"quux.value.key":             9,
+		"quux.value.value":           10,
+		"location":                   11,
+		"location.element":           12,
+		"location.element.latitude":  13,
+		"location.element.longitude": 14,
+		"location.latitude":          13,
+		"location.longitude":         14,
+		"person":                     15,
+		"person.name":                16,
+		"person.age":                 17,
+	}, index)
+}
+
+func TestSchemaFindColumnName(t *testing.T) {
+	tests := []struct {
+		id   int
+		name string
+	}{
+		{1, "foo"},
+		{2, "bar"},
+		{3, "baz"},
+		{4, "qux"},
+		{5, "qux.element"},
+		{6, "quux"},
+		{7, "quux.key"},
+		{8, "quux.value"},
+		{9, "quux.value.key"},
+		{10, "quux.value.value"},
+		{11, "location"},
+		{12, "location.element"},
+		{13, "location.element.latitude"},
+		{14, "location.element.longitude"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			n, ok := tableSchemaNested.FindColumnName(tt.id)
+			assert.True(t, ok)
+			assert.Equal(t, tt.name, n)
+		})
+	}
+}
+
+func TestSchemaFindColumnNameIDNotFound(t *testing.T) {
+	n, ok := tableSchemaNested.FindColumnName(99)
+	assert.False(t, ok)
+	assert.Empty(t, n)
+}
+
+func TestSchemaFindColumnNameByID(t *testing.T) {
+	tests := []struct {
+		id   int
+		name string
+	}{
+		{1, "foo"},
+		{2, "bar"},
+		{3, "baz"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			n, ok := tableSchemaSimple.FindColumnName(tt.id)
+			assert.True(t, ok)
+			assert.Equal(t, tt.name, n)
+		})
+	}
+}
+
+func TestSchemaFindFieldByID(t *testing.T) {
+	index, err := iceberg.IndexByID(tableSchemaSimple)
+	require.NoError(t, err)
+
+	col1 := index[1]
+	assert.Equal(t, 1, col1.ID)
+	assert.Equal(t, iceberg.PrimitiveTypes.String, col1.Type)
+	assert.False(t, col1.Required)
+
+	col2 := index[2]
+	assert.Equal(t, 2, col2.ID)
+	assert.Equal(t, iceberg.PrimitiveTypes.Int32, col2.Type)
+	assert.True(t, col2.Required)
+
+	col3 := index[3]
+	assert.Equal(t, 3, col3.ID)
+	assert.Equal(t, iceberg.PrimitiveTypes.Bool, col3.Type)
+	assert.False(t, col3.Required)
+}
+
+func TestFindFieldByIDUnknownField(t *testing.T) {
+	index, err := iceberg.IndexByID(tableSchemaSimple)
+	require.NoError(t, err)
+	_, ok := index[4]
+	assert.False(t, ok)
+}
+
+func TestSchemaFindField(t *testing.T) {
+	tests := []iceberg.NestedField{
+		{ID: 1, Name: "foo", Type: iceberg.PrimitiveTypes.String, Required: false},
+		{ID: 2, Name: "bar", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+		{ID: 3, Name: "baz", Type: iceberg.PrimitiveTypes.Bool, Required: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			f, ok := tableSchemaSimple.FindFieldByID(tt.ID)
+			assert.True(t, ok)
+			assert.Equal(t, tt, f)
+
+			f, ok = tableSchemaSimple.FindFieldByName(tt.Name)
+			assert.True(t, ok)
+			assert.Equal(t, tt, f)
+
+			f, ok = tableSchemaSimple.FindFieldByNameCaseInsensitive(strings.ToUpper(tt.Name))
+			assert.True(t, ok)
+			assert.Equal(t, tt, f)
+		})
+	}
+}
+
+func TestSchemaFindType(t *testing.T) {
+	_, ok := tableSchemaSimple.FindTypeByID(0)
+	assert.False(t, ok)
+	_, ok = tableSchemaSimple.FindTypeByName("FOOBAR")
+	assert.False(t, ok)
+	_, ok = tableSchemaSimple.FindTypeByNameCaseInsensitive("FOOBAR")
+	assert.False(t, ok)
+
+	tests := []struct {
+		id   int
+		name string
+		typ  iceberg.Type
+	}{
+		{1, "foo", iceberg.PrimitiveTypes.String},
+		{2, "bar", iceberg.PrimitiveTypes.Int32},
+		{3, "baz", iceberg.PrimitiveTypes.Bool},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			typ, ok := tableSchemaSimple.FindTypeByID(tt.id)
+			assert.True(t, ok)
+			assert.Equal(t, tt.typ, typ)
+
+			typ, ok = tableSchemaSimple.FindTypeByName(tt.name)
+			assert.True(t, ok)
+			assert.Equal(t, tt.typ, typ)
+
+			typ, ok = tableSchemaSimple.FindTypeByNameCaseInsensitive(strings.ToUpper(tt.name))
+			assert.True(t, ok)
+			assert.Equal(t, tt.typ, typ)
+		})
+	}
+}
+
+func TestSerializeSchema(t *testing.T) {
+	data, err := json.Marshal(tableSchemaSimple)
+	require.NoError(t, err)
+
+	assert.JSONEq(t, `{
+		"type": "struct",
+		"fields": [
+			{"id": 1, "name": "foo", "type": "string", "required": false},
+			{"id": 2, "name": "bar", "type": "int", "required": true},
+			{"id": 3, "name": "baz", "type": "boolean", "required": false}
+		],
+		"schema-id": 1,
+		"identifier-field-ids": [2]
+	}`, string(data))
+}
+
+func TestUnmarshalSchema(t *testing.T) {
+	var schema iceberg.Schema
+	require.NoError(t, json.Unmarshal([]byte(`{
+		"type": "struct",
+		"fields": [
+			{"id": 1, "name": "foo", "type": "string", "required": false},
+			{"id": 2, "name": "bar", "type": "int", "required": true},
+			{"id": 3, "name": "baz", "type": "boolean", "required": false}
+		],
+		"schema-id": 1,
+		"identifier-field-ids": [2]
+	}`), &schema))
+
+	assert.True(t, tableSchemaSimple.Equals(&schema))
+}
+
+func TestPruneColumnsString(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{1: {}}, false)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchemaWithIdentifiers(1, []int{1},
+		iceberg.NestedField{ID: 1, Name: "foo", Type: iceberg.PrimitiveTypes.String, Required: false})))
+}
+
+func TestPruneColumnsStringFull(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{1: {}}, true)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchemaWithIdentifiers(1, []int{1},
+		iceberg.NestedField{ID: 1, Name: "foo", Type: iceberg.PrimitiveTypes.String, Required: false})))
+}
+
+func TestPruneColumnsList(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{5: {}}, false)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(1,
+		iceberg.NestedField{ID: 4, Name: "qux", Required: true, Type: &iceberg.ListType{
+			ElementID: 5, Element: iceberg.PrimitiveTypes.String, ElementRequired: true,
+		}})))
+}
+
+func TestPruneColumnsListItself(t *testing.T) {
+	_, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{4: {}}, false)
+	assert.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+
+	assert.ErrorContains(t, err, "cannot explicitly project List or Map types, 4:qux of type list<string> was selected")
+}
+
+func TestPruneColumnsListFull(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{5: {}}, true)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(1,
+		iceberg.NestedField{ID: 4, Name: "qux", Required: true, Type: &iceberg.ListType{
+			ElementID: 5, Element: iceberg.PrimitiveTypes.String, ElementRequired: true,
+		}})))
+}
+
+func TestPruneColumnsMap(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{9: {}}, false)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(1,
+		iceberg.NestedField{
+			ID:       6,
+			Name:     "quux",
+			Required: true,
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.String,
+				ValueID: 8,
+				ValueType: &iceberg.MapType{
+					KeyID:         9,
+					KeyType:       iceberg.PrimitiveTypes.String,
+					ValueID:       10,
+					ValueType:     iceberg.PrimitiveTypes.Int32,
+					ValueRequired: true,
+				},
+				ValueRequired: true,
+			},
+		})))
+}
+
+func TestPruneColumnsMapItself(t *testing.T) {
+	_, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{6: {}}, false)
+	assert.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+	assert.ErrorContains(t, err, "cannot explicitly project List or Map types, 6:quux of type map<string, map<string, int>> was selected")
+}
+
+func TestPruneColumnsMapFull(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{9: {}}, true)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(1,
+		iceberg.NestedField{
+			ID:       6,
+			Name:     "quux",
+			Required: true,
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.String,
+				ValueID: 8,
+				ValueType: &iceberg.MapType{
+					KeyID:         9,
+					KeyType:       iceberg.PrimitiveTypes.String,
+					ValueID:       10,
+					ValueType:     iceberg.PrimitiveTypes.Int32,
+					ValueRequired: true,
+				},
+				ValueRequired: true,
+			},
+		})))
+}
+
+func TestPruneColumnsMapKey(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{10: {}}, false)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(1,
+		iceberg.NestedField{
+			ID:       6,
+			Name:     "quux",
+			Required: true,
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.String,
+				ValueID: 8,
+				ValueType: &iceberg.MapType{
+					KeyID:         9,
+					KeyType:       iceberg.PrimitiveTypes.String,
+					ValueID:       10,
+					ValueType:     iceberg.PrimitiveTypes.Int32,
+					ValueRequired: true,
+				},
+				ValueRequired: true,
+			},
+		})))
+}
+
+func TestPruneColumnsStruct(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{16: {}}, false)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(1,
+		iceberg.NestedField{
+			ID:       15,
+			Name:     "person",
+			Required: false,
+			Type: &iceberg.StructType{
+				FieldList: []iceberg.NestedField{{
+					ID: 16, Name: "name", Type: iceberg.PrimitiveTypes.String, Required: false,
+				}},
+			},
+		})))
+}
+
+func TestPruneColumnsStructFull(t *testing.T) {
+	sc, err := iceberg.PruneColumns(tableSchemaNested, map[int]iceberg.Void{16: {}}, true)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(1,
+		iceberg.NestedField{
+			ID:       15,
+			Name:     "person",
+			Required: false,
+			Type: &iceberg.StructType{
+				FieldList: []iceberg.NestedField{{
+					ID: 16, Name: "name", Type: iceberg.PrimitiveTypes.String, Required: false,
+				}},
+			},
+		})))
+}
+
+func TestPruneColumnsEmptyStruct(t *testing.T) {
+	schemaEmptyStruct := iceberg.NewSchema(0, iceberg.NestedField{
+		ID: 15, Name: "person", Type: &iceberg.StructType{}, Required: false,
+	})
+
+	sc, err := iceberg.PruneColumns(schemaEmptyStruct, map[int]iceberg.Void{15: {}}, false)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(0,
+		iceberg.NestedField{
+			ID: 15, Name: "person", Type: &iceberg.StructType{}, Required: false})))
+}
+
+func TestPruneColumnsEmptyStructFull(t *testing.T) {
+	schemaEmptyStruct := iceberg.NewSchema(0, iceberg.NestedField{
+		ID: 15, Name: "person", Type: &iceberg.StructType{}, Required: false,
+	})
+
+	sc, err := iceberg.PruneColumns(schemaEmptyStruct, map[int]iceberg.Void{15: {}}, true)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(iceberg.NewSchema(0,
+		iceberg.NestedField{
+			ID: 15, Name: "person", Type: &iceberg.StructType{}, Required: false})))
+}
+
+func TestPruneColumnsStructInMap(t *testing.T) {
+	nestedSchema := iceberg.NewSchemaWithIdentifiers(1, []int{1},
+		iceberg.NestedField{
+			ID:       6,
+			Name:     "id_to_person",
+			Required: true,
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.Int32,
+				ValueID: 8,
+				ValueType: &iceberg.StructType{
+					FieldList: []iceberg.NestedField{
+						{ID: 10, Name: "name", Type: iceberg.PrimitiveTypes.String},
+						{ID: 11, Name: "age", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+					},
+				},
+				ValueRequired: true,
+			},
+		})
+
+	sc, err := iceberg.PruneColumns(nestedSchema, map[int]iceberg.Void{11: {}}, false)
+	require.NoError(t, err)
+
+	expected := iceberg.NewSchema(1,
+		iceberg.NestedField{
+			ID:       6,
+			Name:     "id_to_person",
+			Required: true,
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.Int32,
+				ValueID: 8,
+				ValueType: &iceberg.StructType{
+					FieldList: []iceberg.NestedField{
+						{ID: 11, Name: "age", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+					},
+				},
+				ValueRequired: true,
+			},
+		})
+
+	assert.Truef(t, sc.Equals(expected), "expected: %s\ngot: %s", expected, sc)
+}
+
+func TestPruneColumnsStructInMapFull(t *testing.T) {
+	nestedSchema := iceberg.NewSchemaWithIdentifiers(1, []int{1},
+		iceberg.NestedField{
+			ID:       6,
+			Name:     "id_to_person",
+			Required: true,
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.Int32,
+				ValueID: 8,
+				ValueType: &iceberg.StructType{
+					FieldList: []iceberg.NestedField{
+						{ID: 10, Name: "name", Type: iceberg.PrimitiveTypes.String},
+						{ID: 11, Name: "age", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+					},
+				},
+				ValueRequired: true,
+			},
+		})
+
+	sc, err := iceberg.PruneColumns(nestedSchema, map[int]iceberg.Void{11: {}}, true)
+	require.NoError(t, err)
+
+	expected := iceberg.NewSchema(1,
+		iceberg.NestedField{
+			ID:       6,
+			Name:     "id_to_person",
+			Required: true,
+			Type: &iceberg.MapType{
+				KeyID:   7,
+				KeyType: iceberg.PrimitiveTypes.Int32,
+				ValueID: 8,
+				ValueType: &iceberg.StructType{
+					FieldList: []iceberg.NestedField{
+						{ID: 11, Name: "age", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+					},
+				},
+				ValueRequired: true,
+			},
+		})
+
+	assert.Truef(t, sc.Equals(expected), "expected: %s\ngot: %s", expected, sc)
+}
+
+func TestPruneColumnsSelectOriginalSchema(t *testing.T) {
+	id := tableSchemaNested.HighestFieldID()
+	selected := make(map[int]iceberg.Void)
+	for i := 0; i < id; i++ {
+		selected[i] = iceberg.Void{}
+	}
+
+	sc, err := iceberg.PruneColumns(tableSchemaNested, selected, true)
+	require.NoError(t, err)
+
+	assert.True(t, sc.Equals(tableSchemaNested))
+}
+
+func TestPruneNilSchema(t *testing.T) {
+	_, err := iceberg.PruneColumns(nil, nil, true)
+	assert.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+}
+
+func TestSchemaRoundTrip(t *testing.T) {
+	data, err := json.Marshal(tableSchemaNested)
+	require.NoError(t, err)
+
+	assert.JSONEq(t, `{
+		"type": "struct",
+		"schema-id": 1,
+		"identifier-field-ids": [1],
+		"fields": [
+			{
+				"type": "string",
+				"id": 1,
+				"name": "foo",
+				"required": false
+			},
+			{
+				"type": "int",
+				"id": 2,
+				"name": "bar",
+				"required": true
+			},
+			{
+				"type": "boolean",
+				"id": 3,
+				"name": "baz",
+				"required": false
+			},
+			{
+				"id": 4,
+				"name": "qux",
+				"required": true,
+				"type": {
+					"type": "list",
+					"element-id": 5,
+					"element-required": true,
+					"element": "string"
+				}
+			},
+			{
+				"id": 6,
+				"name": "quux",
+				"required": true,
+				"type": {
+					"type": "map",
+					"key-id": 7,
+					"key": "string",
+					"value-id": 8,
+					"value": {
+						"type": "map",
+						"key-id": 9,
+						"key": "string",
+						"value-id": 10,
+						"value": "int",
+						"value-required": true
+					},
+					"value-required": true
+				}
+			},
+			{
+				"id": 11,
+				"name": "location",
+				"required": true,
+				"type": {
+					"type": "list",
+					"element-id": 12,
+					"element-required": true,
+					"element": {
+						"type": "struct",
+						"fields": [
+							{
+								"id": 13,
+								"name": "latitude",
+								"type": "float",
+								"required": false
+							},
+							{
+								"id": 14,
+								"name": "longitude",
+								"type": "float",
+								"required": false
+							}
+						]
+					}
+				}
+			},
+			{
+				"id": 15,
+				"name": "person",
+				"required": false,
+				"type": {
+					"type": "struct",
+					"fields": [
+						{
+							"id": 16,
+							"name": "name",
+							"type": "string",
+							"required": false
+						},
+						{
+							"id": 17,
+							"name": "age",
+							"type": "int",
+							"required": true
+						}
+					]
+				}
+			}
+		]
+	}`, string(data))
+
+	var sc iceberg.Schema
+	require.NoError(t, json.Unmarshal(data, &sc))
+
+	assert.Truef(t, tableSchemaNested.Equals(&sc), "expected: %s\ngot: %s", tableSchemaNested, &sc)
+}
diff --git a/types_test.go b/types_test.go
new file mode 100644
index 0000000..abe233f
--- /dev/null
+++ b/types_test.go
@@ -0,0 +1,236 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package iceberg_test
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/apache/iceberg-go"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestTypesBasic(t *testing.T) {
+	tests := []struct {
+		expected string
+		typ      iceberg.Type
+	}{
+		{"boolean", iceberg.PrimitiveTypes.Bool},
+		{"int", iceberg.PrimitiveTypes.Int32},
+		{"long", iceberg.PrimitiveTypes.Int64},
+		{"float", iceberg.PrimitiveTypes.Float32},
+		{"double", iceberg.PrimitiveTypes.Float64},
+		{"date", iceberg.PrimitiveTypes.Date},
+		{"time", iceberg.PrimitiveTypes.Time},
+		{"timestamp", iceberg.PrimitiveTypes.Timestamp},
+		{"timestamptz", iceberg.PrimitiveTypes.TimestampTz},
+		{"uuid", iceberg.PrimitiveTypes.UUID},
+		{"binary", iceberg.PrimitiveTypes.Binary},
+		{"fixed[5]", iceberg.FixedTypeOf(5)},
+		{"decimal(9, 4)", iceberg.DecimalTypeOf(9, 4)},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.expected, func(t *testing.T) {
+			var data = `{
+				"id": 1,
+				"name": "test",
+				"type": "` + tt.expected + `",
+				"required": false
+			}`
+
+			var n iceberg.NestedField
+			require.NoError(t, json.Unmarshal([]byte(data), &n))
+			assert.Truef(t, n.Type.Equals(tt.typ), "expected: %s\ngot: %s", tt.typ, n.Type)
+
+			out, err := json.Marshal(n)
+			require.NoError(t, err)
+			assert.JSONEq(t, data, string(out))
+		})
+	}
+}
+
+func TestFixedType(t *testing.T) {
+	typ := iceberg.FixedTypeOf(5)
+	assert.Equal(t, 5, typ.Len())
+	assert.Equal(t, "fixed[5]", typ.String())
+	assert.True(t, typ.Equals(iceberg.FixedTypeOf(5)))
+	assert.False(t, typ.Equals(iceberg.FixedTypeOf(6)))
+}
+
+func TestDecimalType(t *testing.T) {
+	typ := iceberg.DecimalTypeOf(9, 2)
+	assert.Equal(t, 9, typ.Precision())
+	assert.Equal(t, 2, typ.Scale())
+	assert.Equal(t, "decimal(9, 2)", typ.String())
+	assert.True(t, typ.Equals(iceberg.DecimalTypeOf(9, 2)))
+	assert.False(t, typ.Equals(iceberg.DecimalTypeOf(9, 3)))
+}
+
+func TestStructType(t *testing.T) {
+	typ := &iceberg.StructType{
+		FieldList: []iceberg.NestedField{
+			{ID: 1, Name: "required_field", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+			{ID: 2, Name: "optional_field", Type: iceberg.FixedTypeOf(5), Required: false},
+			{ID: 3, Name: "required_field", Type: &iceberg.StructType{
+				FieldList: []iceberg.NestedField{
+					{ID: 4, Name: "optional_field", Type: iceberg.DecimalTypeOf(8, 2), Required: false},
+					{ID: 5, Name: "required_field", Type: iceberg.PrimitiveTypes.Int64, Required: false},
+				},
+			}, Required: false},
+		},
+	}
+
+	assert.Len(t, typ.FieldList, 3)
+	assert.False(t, typ.Equals(&iceberg.StructType{FieldList: []iceberg.NestedField{{ID: 1, Name: "optional_field", Type: iceberg.PrimitiveTypes.Int32, Required: true}}}))
+	out, err := json.Marshal(typ)
+	require.NoError(t, err)
+
+	var actual iceberg.StructType
+	require.NoError(t, json.Unmarshal(out, &actual))
+	assert.True(t, typ.Equals(&actual))
+}
+
+func TestListType(t *testing.T) {
+	typ := &iceberg.ListType{
+		ElementID:       1,
+		ElementRequired: false,
+		Element: &iceberg.StructType{
+			FieldList: []iceberg.NestedField{
+				{ID: 2, Name: "required_field", Type: iceberg.DecimalTypeOf(8, 2), Required: true},
+				{ID: 3, Name: "optional_field", Type: iceberg.PrimitiveTypes.Int64, Required: false},
+			},
+		},
+	}
+
+	assert.IsType(t, (*iceberg.StructType)(nil), typ.ElementField().Type)
+	assert.Len(t, typ.ElementField().Type.(iceberg.NestedType).Fields(), 2)
+	assert.Equal(t, 1, typ.ElementField().ID)
+	assert.False(t, typ.Equals(&iceberg.ListType{
+		ElementID:       1,
+		ElementRequired: true,
+		Element: &iceberg.StructType{
+			FieldList: []iceberg.NestedField{
+				{ID: 2, Name: "required_field", Type: iceberg.DecimalTypeOf(8, 2), Required: true},
+			},
+		},
+	}))
+
+	out, err := json.Marshal(typ)
+	require.NoError(t, err)
+
+	var actual iceberg.ListType
+	require.NoError(t, json.Unmarshal(out, &actual))
+	assert.True(t, typ.Equals(&actual))
+}
+
+func TestMapType(t *testing.T) {
+	typ := &iceberg.MapType{
+		KeyID:         1,
+		KeyType:       iceberg.PrimitiveTypes.Float64,
+		ValueID:       2,
+		ValueType:     iceberg.PrimitiveTypes.UUID,
+		ValueRequired: false,
+	}
+
+	assert.IsType(t, iceberg.PrimitiveTypes.Float64, typ.KeyField().Type)
+	assert.Equal(t, 1, typ.KeyField().ID)
+	assert.IsType(t, iceberg.PrimitiveTypes.UUID, typ.ValueField().Type)
+	assert.Equal(t, 2, typ.ValueField().ID)
+	assert.False(t, typ.Equals(&iceberg.MapType{
+		KeyID: 1, KeyType: iceberg.PrimitiveTypes.Int64,
+		ValueID: 2, ValueType: iceberg.PrimitiveTypes.UUID, ValueRequired: false,
+	}))
+	assert.False(t, typ.Equals(&iceberg.MapType{
+		KeyID: 1, KeyType: iceberg.PrimitiveTypes.Float64,
+		ValueID: 2, ValueType: iceberg.PrimitiveTypes.String, ValueRequired: true,
+	}))
+
+	out, err := json.Marshal(typ)
+	require.NoError(t, err)
+
+	var actual iceberg.MapType
+	require.NoError(t, json.Unmarshal(out, &actual))
+	assert.True(t, typ.Equals(&actual))
+}
+
+var (
+	NonParameterizedTypes = []iceberg.Type{
+		iceberg.PrimitiveTypes.Bool,
+		iceberg.PrimitiveTypes.Int32,
+		iceberg.PrimitiveTypes.Int64,
+		iceberg.PrimitiveTypes.Float32,
+		iceberg.PrimitiveTypes.Float64,
+		iceberg.PrimitiveTypes.Date,
+		iceberg.PrimitiveTypes.Time,
+		iceberg.PrimitiveTypes.Timestamp,
+		iceberg.PrimitiveTypes.TimestampTz,
+		iceberg.PrimitiveTypes.String,
+		iceberg.PrimitiveTypes.Binary,
+		iceberg.PrimitiveTypes.UUID,
+	}
+)
+
+func TestNonParameterizedTypeEquality(t *testing.T) {
+	for i, in := range NonParameterizedTypes {
+		for j, check := range NonParameterizedTypes {
+			if i == j {
+				assert.Truef(t, in.Equals(check), "expected %s == %s", in, check)
+			} else {
+				assert.Falsef(t, in.Equals(check), "expected %s != %s", in, check)
+			}
+		}
+	}
+}
+
+func TestTypeStrings(t *testing.T) {
+	tests := []struct {
+		typ iceberg.Type
+		str string
+	}{
+		{iceberg.PrimitiveTypes.Bool, "boolean"},
+		{iceberg.PrimitiveTypes.Int32, "int"},
+		{iceberg.PrimitiveTypes.Int64, "long"},
+		{iceberg.PrimitiveTypes.Float32, "float"},
+		{iceberg.PrimitiveTypes.Float64, "double"},
+		{iceberg.PrimitiveTypes.Date, "date"},
+		{iceberg.PrimitiveTypes.Time, "time"},
+		{iceberg.PrimitiveTypes.Timestamp, "timestamp"},
+		{iceberg.PrimitiveTypes.TimestampTz, "timestamptz"},
+		{iceberg.PrimitiveTypes.String, "string"},
+		{iceberg.PrimitiveTypes.UUID, "uuid"},
+		{iceberg.PrimitiveTypes.Binary, "binary"},
+		{iceberg.FixedTypeOf(22), "fixed[22]"},
+		{iceberg.DecimalTypeOf(19, 25), "decimal(19, 25)"},
+		{&iceberg.StructType{
+			FieldList: []iceberg.NestedField{
+				{ID: 1, Name: "required_field", Type: iceberg.PrimitiveTypes.String, Required: true, Doc: "this is a doc"},
+				{ID: 2, Name: "optional_field", Type: iceberg.PrimitiveTypes.Int32, Required: true},
+			},
+		}, "struct<1: required_field: required string (this is a doc), 2: optional_field: required int>"},
+		{&iceberg.ListType{
+			ElementID: 22, Element: iceberg.PrimitiveTypes.String}, "list<string>"},
+		{&iceberg.MapType{KeyID: 19, KeyType: iceberg.PrimitiveTypes.String, ValueID: 25, ValueType: iceberg.PrimitiveTypes.Float64},
+			"map<string, double>"},
+	}
+
+	for _, tt := range tests {
+		assert.Equal(t, tt.str, tt.typ.String())
+	}
+}