You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ga...@apache.org on 2026/04/16 15:13:04 UTC

(iceberg-cpp) branch main updated: feat: add s3 file io integration (#548)

This is an automated email from the ASF dual-hosted git repository.

gangwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new a5fc25f3 feat: add s3 file io integration (#548)
a5fc25f3 is described below

commit a5fc25f3ab3444670af3f91a75a532c64c53510a
Author: liuxiaoyu <45...@users.noreply.github.com>
AuthorDate: Thu Apr 16 23:12:58 2026 +0800

    feat: add s3 file io integration (#548)
---
 .github/workflows/test.yml                         |  36 ++++
 CMakeLists.txt                                     |   7 +
 ci/scripts/build_iceberg.sh                        |   1 +
 ci/scripts/start_minio.sh                          | 153 +++++++++++++++++
 cmake_modules/IcebergThirdpartyToolchain.cmake     |   8 +
 src/iceberg/CMakeLists.txt                         |  15 ++
 src/iceberg/arrow/CMakeLists.txt                   |   2 +
 src/iceberg/arrow/arrow_file_io.h                  |  20 +++
 src/iceberg/arrow/arrow_fs_file_io.cc              |  22 ++-
 src/iceberg/arrow/arrow_fs_file_io_internal.h      |   3 +
 src/iceberg/arrow/file_io_register.cc              |  61 +++++++
 .../arrow/{arrow_file_io.h => file_io_register.h}  |  11 +-
 src/iceberg/arrow/{ => s3}/CMakeLists.txt          |   2 +-
 src/iceberg/arrow/s3/arrow_s3_file_io.cc           | 184 +++++++++++++++++++++
 src/iceberg/arrow/s3/s3_properties.h               |  54 ++++++
 src/iceberg/catalog/rest/CMakeLists.txt            |   1 +
 src/iceberg/catalog/rest/catalog_properties.h      |   2 +
 src/iceberg/catalog/rest/meson.build               |   2 +
 src/iceberg/catalog/rest/rest_catalog.cc           |  10 +-
 src/iceberg/catalog/rest/rest_catalog.h            |   9 +-
 src/iceberg/catalog/rest/rest_file_io.cc           |  95 +++++++++++
 .../rest/rest_file_io.h}                           |  24 ++-
 src/iceberg/file_io_registry.cc                    |  61 +++++++
 src/iceberg/file_io_registry.h                     |  64 +++++++
 src/iceberg/meson.build                            |   6 +-
 src/iceberg/test/CMakeLists.txt                    |   5 +
 src/iceberg/test/arrow_s3_file_io_test.cc          | 184 +++++++++++++++++++++
 src/iceberg/test/meson.build                       |   1 +
 src/iceberg/test/rest_catalog_integration_test.cc  |  12 +-
 src/iceberg/test/rest_file_io_test.cc              | 144 ++++++++++++++++
 30 files changed, 1171 insertions(+), 28 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5eb26e06..c60f42b1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -41,12 +41,24 @@ jobs:
     name: AMD64 Ubuntu 24.04
     runs-on: ubuntu-24.04
     timeout-minutes: 30
+    strategy:
+      fail-fast: false
+    env:
+      ICEBERG_TEST_S3_URI: s3://iceberg-test
+      AWS_ACCESS_KEY_ID: minio
+      AWS_SECRET_ACCESS_KEY: minio123
+      AWS_DEFAULT_REGION: us-east-1
+      AWS_ENDPOINT_URL: http://127.0.0.1:9000
+      AWS_EC2_METADATA_DISABLED: "TRUE"
     steps:
       - name: Checkout iceberg-cpp
         uses: actions/checkout@v6
       - name: Install dependencies
         shell: bash
         run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev
+      - name: Start MinIO
+        shell: bash
+        run: bash ci/scripts/start_minio.sh
       - name: Build Iceberg
         shell: bash
         env:
@@ -63,9 +75,21 @@ jobs:
     name: AArch64 macOS 26
     runs-on: macos-26
     timeout-minutes: 30
+    strategy:
+      fail-fast: false
+    env:
+      ICEBERG_TEST_S3_URI: s3://iceberg-test
+      AWS_ACCESS_KEY_ID: minio
+      AWS_SECRET_ACCESS_KEY: minio123
+      AWS_DEFAULT_REGION: us-east-1
+      AWS_ENDPOINT_URL: http://127.0.0.1:9000
+      AWS_EC2_METADATA_DISABLED: "TRUE"
     steps:
       - name: Checkout iceberg-cpp
         uses: actions/checkout@v6
+      - name: Start MinIO
+        shell: bash
+        run: bash ci/scripts/start_minio.sh
       - name: Build Iceberg
         shell: bash
         run: ci/scripts/build_iceberg.sh $(pwd)
@@ -76,6 +100,15 @@ jobs:
     name: AMD64 Windows 2025
     runs-on: windows-2025
     timeout-minutes: 60
+    strategy:
+      fail-fast: false
+    env:
+      ICEBERG_TEST_S3_URI: s3://iceberg-test
+      AWS_ACCESS_KEY_ID: minio
+      AWS_SECRET_ACCESS_KEY: minio123
+      AWS_DEFAULT_REGION: us-east-1
+      AWS_ENDPOINT_URL: http://127.0.0.1:9000
+      AWS_EC2_METADATA_DISABLED: "TRUE"
     steps:
       - name: Checkout iceberg-cpp
         uses: actions/checkout@v6
@@ -85,6 +118,9 @@ jobs:
           vcpkg install zlib:x64-windows nlohmann-json:x64-windows nanoarrow:x64-windows roaring:x64-windows cpr:x64-windows
       - name: Setup sccache
         uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
+      - name: Start MinIO
+        shell: bash
+        run: bash ci/scripts/start_minio.sh
       - name: Build Iceberg
         shell: cmd
         env:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e7281fb1..c0f2eb73 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -45,6 +45,7 @@ option(ICEBERG_BUILD_TESTS "Build tests" ON)
 option(ICEBERG_BUILD_BUNDLE "Build the battery included library" ON)
 option(ICEBERG_BUILD_REST "Build rest catalog client" ON)
 option(ICEBERG_BUILD_REST_INTEGRATION_TESTS "Build rest catalog integration tests" OFF)
+option(ICEBERG_S3 "Build with S3 support" OFF)
 option(ICEBERG_ENABLE_ASAN "Enable Address Sanitizer" OFF)
 option(ICEBERG_ENABLE_UBSAN "Enable Undefined Behavior Sanitizer" OFF)
 
@@ -68,6 +69,12 @@ if(ICEBERG_BUILD_REST_INTEGRATION_TESTS AND WIN32)
   message(WARNING "Cannot build rest integration test on Windows, turning it off.")
 endif()
 
+# ICEBERG_S3 requires ICEBERG_BUILD_BUNDLE
+if(NOT ICEBERG_BUILD_BUNDLE AND ICEBERG_S3)
+  set(ICEBERG_S3 OFF)
+  message(STATUS "ICEBERG_S3 is disabled because ICEBERG_BUILD_BUNDLE is OFF")
+endif()
+
 include(CMakeParseArguments)
 include(IcebergBuildUtils)
 include(IcebergSanitizer)
diff --git a/ci/scripts/build_iceberg.sh b/ci/scripts/build_iceberg.sh
index 102c8ec1..cf332c21 100755
--- a/ci/scripts/build_iceberg.sh
+++ b/ci/scripts/build_iceberg.sh
@@ -36,6 +36,7 @@ CMAKE_ARGS=(
     "-DCMAKE_INSTALL_PREFIX=${CMAKE_INSTALL_PREFIX:-${ICEBERG_HOME}}"
     "-DICEBERG_BUILD_STATIC=ON"
     "-DICEBERG_BUILD_SHARED=ON"
+    "-DICEBERG_S3=ON"
     "-DICEBERG_BUILD_REST_INTEGRATION_TESTS=${build_rest_integration_test}"
 )
 
diff --git a/ci/scripts/start_minio.sh b/ci/scripts/start_minio.sh
new file mode 100644
index 00000000..026be670
--- /dev/null
+++ b/ci/scripts/start_minio.sh
@@ -0,0 +1,153 @@
+#!/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.
+
+set -eux
+
+MINIO_ROOT_USER="${MINIO_ROOT_USER:-minio}"
+MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-minio123}"
+MINIO_IMAGE="${MINIO_IMAGE:-minio/minio:latest}"
+MINIO_CONTAINER_NAME="${MINIO_CONTAINER_NAME:-iceberg-minio}"
+MINIO_PORT="${MINIO_PORT:-9000}"
+MINIO_CONSOLE_PORT="${MINIO_CONSOLE_PORT:-9001}"
+MINIO_BUCKET="${MINIO_BUCKET:-iceberg-test}"
+MINIO_ENDPOINT="${MINIO_ENDPOINT:-http://127.0.0.1:${MINIO_PORT}}"
+
+wait_for_minio() {
+  for i in {1..30}; do
+    if curl -fsS "${MINIO_ENDPOINT}/minio/health/ready" >/dev/null; then
+      return 0
+    fi
+    sleep 1
+  done
+  echo "MinIO did not become ready after 30 seconds." >&2
+  echo "Endpoint: ${MINIO_ENDPOINT}" >&2
+  if command -v docker >/dev/null 2>&1; then
+    docker logs "${MINIO_CONTAINER_NAME}" 2>&1 || true
+  fi
+  return 1
+}
+
+start_minio_docker() {
+  if ! command -v docker >/dev/null 2>&1; then
+    return 1
+  fi
+
+  if docker ps -a --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER_NAME}\$"; then
+    docker rm -f "${MINIO_CONTAINER_NAME}"
+  fi
+
+  docker run -d --name "${MINIO_CONTAINER_NAME}" \
+    -p "${MINIO_PORT}:9000" -p "${MINIO_CONSOLE_PORT}:9001" \
+    -e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
+    -e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
+    "${MINIO_IMAGE}" \
+    server /data --console-address ":${MINIO_CONSOLE_PORT}"
+
+  wait_for_minio
+}
+
+start_minio_macos() {
+  if ! command -v brew >/dev/null 2>&1; then
+    echo "brew is required to start MinIO on macOS without Docker" >&2
+    return 1
+  fi
+
+  brew install minio
+  MINIO_ROOT_USER="${MINIO_ROOT_USER}" MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD}" \
+    minio server /tmp/minio --console-address ":${MINIO_CONSOLE_PORT}" &
+  wait_for_minio
+}
+
+download_mc() {
+  local uname_out
+  uname_out="$(uname -s)"
+
+  local mc_dir
+  mc_dir="${RUNNER_TEMP:-/tmp}"
+  mkdir -p "${mc_dir}"
+
+  case "${uname_out}" in
+    Linux*)
+      MC_BIN="${mc_dir}/mc"
+      curl -sSL "https://dl.min.io/client/mc/release/linux-amd64/mc" -o "${MC_BIN}"
+      chmod +x "${MC_BIN}"
+      ;;
+    Darwin*)
+      MC_BIN="${mc_dir}/mc"
+      local arch
+      arch="$(uname -m)"
+      if [ "${arch}" = "arm64" ]; then
+        curl -sSL "https://dl.min.io/client/mc/release/darwin-arm64/mc" -o "${MC_BIN}"
+      else
+        curl -sSL "https://dl.min.io/client/mc/release/darwin-amd64/mc" -o "${MC_BIN}"
+      fi
+      chmod +x "${MC_BIN}"
+      ;;
+    MINGW*|MSYS*|CYGWIN*)
+      MC_BIN="${mc_dir}/mc.exe"
+      curl -sSL "https://dl.min.io/client/mc/release/windows-amd64/mc.exe" -o "${MC_BIN}"
+      ;;
+    *)
+      echo "Unsupported OS for mc: ${uname_out}" >&2
+      return 1
+      ;;
+  esac
+}
+
+create_bucket() {
+  download_mc
+  for i in {1..30}; do
+    if "${MC_BIN}" alias set local "${MINIO_ENDPOINT}" "${MINIO_ROOT_USER}" "${MINIO_ROOT_PASSWORD}"; then
+      break
+    fi
+    sleep 1
+  done
+  "${MC_BIN}" mb --ignore-existing "local/${MINIO_BUCKET}"
+}
+
+start_minio_windows() {
+  local minio_dir="${RUNNER_TEMP:-/tmp}"
+  local minio_bin="${minio_dir}/minio.exe"
+  curl -sSL "https://dl.min.io/server/minio/release/windows-amd64/minio.exe" -o "${minio_bin}"
+  MINIO_ROOT_USER="${MINIO_ROOT_USER}" MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD}" \
+    "${minio_bin}" server "${minio_dir}/minio-data" --console-address ":${MINIO_CONSOLE_PORT}" &
+  wait_for_minio
+}
+
+case "$(uname -s)" in
+  Darwin*)
+    if ! start_minio_docker; then
+      start_minio_macos
+    fi
+    ;;
+  MINGW*|MSYS*|CYGWIN*)
+    if ! start_minio_docker; then
+      start_minio_windows
+    fi
+    ;;
+  Linux*)
+    start_minio_docker
+    ;;
+  *)
+    echo "Unsupported OS: $(uname -s)" >&2
+    exit 1
+    ;;
+esac
+
+create_bucket
diff --git a/cmake_modules/IcebergThirdpartyToolchain.cmake b/cmake_modules/IcebergThirdpartyToolchain.cmake
index 8b32eb74..d4f837d6 100644
--- a/cmake_modules/IcebergThirdpartyToolchain.cmake
+++ b/cmake_modules/IcebergThirdpartyToolchain.cmake
@@ -102,6 +102,7 @@ function(resolve_arrow_dependency)
   # Work around undefined symbol: arrow::ipc::ReadSchema(arrow::io::InputStream*, arrow::ipc::DictionaryMemo*)
   set(ARROW_IPC ON)
   set(ARROW_FILESYSTEM ON)
+  set(ARROW_S3 ${ICEBERG_S3})
   set(ARROW_JSON ON)
   set(ARROW_PARQUET ON)
   set(ARROW_SIMD_LEVEL "NONE")
@@ -164,6 +165,13 @@ function(resolve_arrow_dependency)
       install(FILES ${arrow_bundled_dependencies_location}
               DESTINATION ${ICEBERG_INSTALL_LIBDIR})
     endif()
+
+    # Arrow's exported static target interface may reference system libraries
+    # (e.g. OpenSSL, CURL, ZLIB) that consumers need to find.
+    list(APPEND ICEBERG_SYSTEM_DEPENDENCIES ZLIB)
+    if(ARROW_S3)
+      list(APPEND ICEBERG_SYSTEM_DEPENDENCIES OpenSSL CURL)
+    endif()
   else()
     set(ARROW_VENDORED FALSE)
     find_package(Arrow CONFIG REQUIRED)
diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt
index 4cd5e92c..1c6a13cb 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -43,6 +43,7 @@ set(ICEBERG_SOURCES
     expression/rewrite_not.cc
     expression/strict_metrics_evaluator.cc
     expression/term.cc
+    file_io_registry.cc
     file_reader.cc
     file_writer.cc
     inheritable_metadata.cc
@@ -181,6 +182,8 @@ add_subdirectory(util)
 if(ICEBERG_BUILD_BUNDLE)
   set(ICEBERG_BUNDLE_SOURCES
       arrow/arrow_fs_file_io.cc
+      arrow/s3/arrow_s3_file_io.cc
+      arrow/file_io_register.cc
       arrow/metadata_column_util.cc
       avro/avro_data_util.cc
       avro/avro_direct_decoder.cc
@@ -247,6 +250,18 @@ if(ICEBERG_BUILD_BUNDLE)
                   OUTPUTS
                   ICEBERG_BUNDLE_LIBRARIES)
 
+  foreach(target iceberg_bundle_static iceberg_bundle_shared)
+    if(TARGET ${target})
+      if(ICEBERG_S3)
+        target_compile_definitions(${target}
+                                   PUBLIC "$<BUILD_INTERFACE:ICEBERG_S3_ENABLED=1>")
+      else()
+        target_compile_definitions(${target}
+                                   PUBLIC "$<BUILD_INTERFACE:ICEBERG_S3_ENABLED=0>")
+      endif()
+    endif()
+  endforeach()
+
   add_subdirectory(arrow)
   add_subdirectory(avro)
   add_subdirectory(parquet)
diff --git a/src/iceberg/arrow/CMakeLists.txt b/src/iceberg/arrow/CMakeLists.txt
index 3416d5e9..71c161f0 100644
--- a/src/iceberg/arrow/CMakeLists.txt
+++ b/src/iceberg/arrow/CMakeLists.txt
@@ -16,3 +16,5 @@
 # under the License.
 
 iceberg_install_all_headers(iceberg/arrow)
+
+add_subdirectory(s3)
diff --git a/src/iceberg/arrow/arrow_file_io.h b/src/iceberg/arrow/arrow_file_io.h
index 12a9b230..e33826dc 100644
--- a/src/iceberg/arrow/arrow_file_io.h
+++ b/src/iceberg/arrow/arrow_file_io.h
@@ -20,9 +20,12 @@
 #pragma once
 
 #include <memory>
+#include <string>
+#include <unordered_map>
 
 #include "iceberg/file_io.h"
 #include "iceberg/iceberg_bundle_export.h"
+#include "iceberg/result.h"
 
 namespace iceberg::arrow {
 
@@ -30,4 +33,21 @@ ICEBERG_BUNDLE_EXPORT std::unique_ptr<FileIO> MakeMockFileIO();
 
 ICEBERG_BUNDLE_EXPORT std::unique_ptr<FileIO> MakeLocalFileIO();
 
+/// \brief Create an S3 FileIO backed by Arrow's S3FileSystem.
+///
+/// This function initializes the S3 subsystem if not already initialized (thread-safe).
+/// The S3 initialization is cached once per process.
+///
+/// \param properties Configuration properties for S3 access. See S3Properties
+///        for available keys (credentials, region, endpoint, timeouts, etc.).
+/// \return A FileIO instance for S3 operations, or an error if S3 is not supported.
+ICEBERG_BUNDLE_EXPORT Result<std::unique_ptr<FileIO>> MakeS3FileIO(
+    const std::unordered_map<std::string, std::string>& properties = {});
+
+/// \brief Finalize (clean up) the Arrow S3 subsystem.
+///
+/// Must be called before process exit if S3 was initialized, otherwise Arrow's
+/// static destructors may cause a non-zero exit.
+ICEBERG_BUNDLE_EXPORT Status FinalizeS3();
+
 }  // namespace iceberg::arrow
diff --git a/src/iceberg/arrow/arrow_fs_file_io.cc b/src/iceberg/arrow/arrow_fs_file_io.cc
index be62b79a..769fcfb1 100644
--- a/src/iceberg/arrow/arrow_fs_file_io.cc
+++ b/src/iceberg/arrow/arrow_fs_file_io.cc
@@ -25,13 +25,23 @@
 #include "iceberg/arrow/arrow_file_io.h"
 #include "iceberg/arrow/arrow_fs_file_io_internal.h"
 #include "iceberg/arrow/arrow_status_internal.h"
+#include "iceberg/util/macros.h"
 
 namespace iceberg::arrow {
 
+Result<std::string> ArrowFileSystemFileIO::ResolvePath(const std::string& file_location) {
+  if (file_location.find("://") != std::string::npos) {
+    ICEBERG_ARROW_ASSIGN_OR_RETURN(auto path, arrow_fs_->PathFromUri(file_location));
+    return path;
+  }
+  return file_location;
+}
+
 /// \brief Read the content of the file at the given location.
 Result<std::string> ArrowFileSystemFileIO::ReadFile(const std::string& file_location,
                                                     std::optional<size_t> length) {
-  ::arrow::fs::FileInfo file_info(file_location);
+  ICEBERG_ASSIGN_OR_RAISE(auto path, ResolvePath(file_location));
+  ::arrow::fs::FileInfo file_info(path);
   if (length.has_value()) {
     file_info.set_size(length.value());
   }
@@ -47,6 +57,10 @@ Result<std::string> ArrowFileSystemFileIO::ReadFile(const std::string& file_loca
     ICEBERG_ARROW_ASSIGN_OR_RETURN(
         auto read_bytes,
         file->Read(read_length, reinterpret_cast<uint8_t*>(&content[offset])));
+    if (read_bytes == 0) {
+      return IOError("Unexpected EOF reading {}: got {} of {} bytes", file_location,
+                     offset, file_size);
+    }
     remain -= read_bytes;
     offset += read_bytes;
   }
@@ -57,7 +71,8 @@ Result<std::string> ArrowFileSystemFileIO::ReadFile(const std::string& file_loca
 /// \brief Write the given content to the file at the given location.
 Status ArrowFileSystemFileIO::WriteFile(const std::string& file_location,
                                         std::string_view content) {
-  ICEBERG_ARROW_ASSIGN_OR_RETURN(auto file, arrow_fs_->OpenOutputStream(file_location));
+  ICEBERG_ASSIGN_OR_RAISE(auto path, ResolvePath(file_location));
+  ICEBERG_ARROW_ASSIGN_OR_RETURN(auto file, arrow_fs_->OpenOutputStream(path));
   ICEBERG_ARROW_RETURN_NOT_OK(file->Write(content.data(), content.size()));
   ICEBERG_ARROW_RETURN_NOT_OK(file->Flush());
   ICEBERG_ARROW_RETURN_NOT_OK(file->Close());
@@ -66,7 +81,8 @@ Status ArrowFileSystemFileIO::WriteFile(const std::string& file_location,
 
 /// \brief Delete a file at the given location.
 Status ArrowFileSystemFileIO::DeleteFile(const std::string& file_location) {
-  ICEBERG_ARROW_RETURN_NOT_OK(arrow_fs_->DeleteFile(file_location));
+  ICEBERG_ASSIGN_OR_RAISE(auto path, ResolvePath(file_location));
+  ICEBERG_ARROW_RETURN_NOT_OK(arrow_fs_->DeleteFile(path));
   return {};
 }
 
diff --git a/src/iceberg/arrow/arrow_fs_file_io_internal.h b/src/iceberg/arrow/arrow_fs_file_io_internal.h
index f151c7a5..92a99150 100644
--- a/src/iceberg/arrow/arrow_fs_file_io_internal.h
+++ b/src/iceberg/arrow/arrow_fs_file_io_internal.h
@@ -56,6 +56,9 @@ class ICEBERG_BUNDLE_EXPORT ArrowFileSystemFileIO : public FileIO {
   const std::shared_ptr<::arrow::fs::FileSystem>& fs() const { return arrow_fs_; }
 
  private:
+  /// \brief Resolve a file location to a filesystem path.
+  Result<std::string> ResolvePath(const std::string& file_location);
+
   std::shared_ptr<::arrow::fs::FileSystem> arrow_fs_;
 };
 
diff --git a/src/iceberg/arrow/file_io_register.cc b/src/iceberg/arrow/file_io_register.cc
new file mode 100644
index 00000000..1140e49b
--- /dev/null
+++ b/src/iceberg/arrow/file_io_register.cc
@@ -0,0 +1,61 @@
+// 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.
+
+#include "iceberg/arrow/file_io_register.h"
+
+#include <mutex>
+#include <string>
+
+#include "iceberg/arrow/arrow_file_io.h"
+#include "iceberg/file_io_registry.h"
+
+namespace iceberg::arrow {
+
+namespace {
+
+void RegisterLocalFileIO() {
+  FileIORegistry::Register(
+      std::string(FileIORegistry::kArrowLocalFileIO),
+      [](const std::unordered_map<std::string, std::string>& /*properties*/)
+          -> Result<std::unique_ptr<FileIO>> { return MakeLocalFileIO(); });
+}
+
+void RegisterS3FileIO() {
+#if ICEBERG_S3_ENABLED
+  FileIORegistry::Register(
+      std::string(FileIORegistry::kArrowS3FileIO),
+      [](const std::unordered_map<std::string, std::string>& properties)
+          -> Result<std::unique_ptr<FileIO>> { return MakeS3FileIO(properties); });
+#endif
+}
+
+}  // namespace
+
+void EnsureArrowFileIOsRegistered() {
+  static std::once_flag flag;
+  std::call_once(flag, []() {
+    RegisterLocalFileIO();
+    RegisterS3FileIO();
+  });
+}
+
+[[maybe_unused]] const bool kArrowFileIOsRegistered = []() {
+  EnsureArrowFileIOsRegistered();
+  return true;
+}();
+
+}  // namespace iceberg::arrow
diff --git a/src/iceberg/arrow/arrow_file_io.h b/src/iceberg/arrow/file_io_register.h
similarity index 74%
copy from src/iceberg/arrow/arrow_file_io.h
copy to src/iceberg/arrow/file_io_register.h
index 12a9b230..1b4622bd 100644
--- a/src/iceberg/arrow/arrow_file_io.h
+++ b/src/iceberg/arrow/file_io_register.h
@@ -19,15 +19,16 @@
 
 #pragma once
 
-#include <memory>
+/// \file iceberg/arrow/file_io_register.h
+/// \brief Provide functions to register Arrow FileIO implementations.
 
-#include "iceberg/file_io.h"
 #include "iceberg/iceberg_bundle_export.h"
 
 namespace iceberg::arrow {
 
-ICEBERG_BUNDLE_EXPORT std::unique_ptr<FileIO> MakeMockFileIO();
-
-ICEBERG_BUNDLE_EXPORT std::unique_ptr<FileIO> MakeLocalFileIO();
+/// \brief Register built-in Arrow FileIO implementations into the FileIORegistry.
+///
+/// This operation is idempotent and safe to call multiple times.
+ICEBERG_BUNDLE_EXPORT void EnsureArrowFileIOsRegistered();
 
 }  // namespace iceberg::arrow
diff --git a/src/iceberg/arrow/CMakeLists.txt b/src/iceberg/arrow/s3/CMakeLists.txt
similarity index 94%
copy from src/iceberg/arrow/CMakeLists.txt
copy to src/iceberg/arrow/s3/CMakeLists.txt
index 3416d5e9..27eda89e 100644
--- a/src/iceberg/arrow/CMakeLists.txt
+++ b/src/iceberg/arrow/s3/CMakeLists.txt
@@ -15,4 +15,4 @@
 # specific language governing permissions and limitations
 # under the License.
 
-iceberg_install_all_headers(iceberg/arrow)
+iceberg_install_all_headers(iceberg/arrow/s3)
diff --git a/src/iceberg/arrow/s3/arrow_s3_file_io.cc b/src/iceberg/arrow/s3/arrow_s3_file_io.cc
new file mode 100644
index 00000000..808415d0
--- /dev/null
+++ b/src/iceberg/arrow/s3/arrow_s3_file_io.cc
@@ -0,0 +1,184 @@
+/*
+ * 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.
+ */
+
+#include <cstdlib>
+#include <optional>
+#include <string>
+#include <string_view>
+
+#include <arrow/filesystem/filesystem.h>
+#if ICEBERG_S3_ENABLED
+#  include <arrow/filesystem/s3fs.h>
+#endif
+
+#include "iceberg/arrow/arrow_file_io.h"
+#include "iceberg/arrow/arrow_fs_file_io_internal.h"
+#include "iceberg/arrow/arrow_status_internal.h"
+#include "iceberg/arrow/s3/s3_properties.h"
+#include "iceberg/util/macros.h"
+#include "iceberg/util/string_util.h"
+
+namespace iceberg::arrow {
+
+namespace {
+
+#if ICEBERG_S3_ENABLED
+const std::string* FindProperty(
+    const std::unordered_map<std::string, std::string>& properties,
+    std::string_view key) {
+  auto it = properties.find(std::string(key));
+  return it == properties.end() ? nullptr : &it->second;
+}
+
+Result<std::optional<bool>> ParseOptionalBool(
+    const std::unordered_map<std::string, std::string>& properties,
+    std::string_view key) {
+  const auto* value = FindProperty(properties, key);
+  if (value == nullptr) {
+    return std::nullopt;
+  }
+  if (*value == "true") {
+    return true;
+  }
+  if (*value == "false") {
+    return false;
+  }
+  return InvalidArgument(R"("{}" must be "true" or "false")", key);
+}
+
+Status EnsureS3Initialized() {
+  static const ::arrow::Status init_status = []() {
+    auto options = ::arrow::fs::S3GlobalOptions::Defaults();
+    return ::arrow::fs::InitializeS3(options);
+  }();
+  if (!init_status.ok()) {
+    return std::unexpected(Error{.kind = ::iceberg::arrow::ToErrorKind(init_status),
+                                 .message = init_status.ToString()});
+  }
+  return {};
+}
+
+/// \brief Configure S3Options from a properties map.
+///
+/// \param properties The configuration properties map.
+/// \return Configured S3Options.
+Result<::arrow::fs::S3Options> ConfigureS3Options(
+    const std::unordered_map<std::string, std::string>& properties) {
+  auto options = ::arrow::fs::S3Options::Defaults();
+
+  // Configure credentials
+  const auto* access_key = FindProperty(properties, S3Properties::kAccessKeyId);
+  const auto* secret_key = FindProperty(properties, S3Properties::kSecretAccessKey);
+  const auto* session_token = FindProperty(properties, S3Properties::kSessionToken);
+
+  if ((access_key == nullptr) != (secret_key == nullptr)) {
+    return InvalidArgument(
+        "S3 client access key ID and secret access key must be set at the same time");
+  }
+  if (access_key != nullptr) {
+    if (session_token != nullptr) {
+      options.ConfigureAccessKey(*access_key, *secret_key, *session_token);
+    } else {
+      options.ConfigureAccessKey(*access_key, *secret_key);
+    }
+  }
+
+  // Configure region
+  if (const auto* region = FindProperty(properties, S3Properties::kRegion);
+      region != nullptr) {
+    options.region = *region;
+  }
+
+  // Configure endpoint (for MinIO, LocalStack, etc.)
+  if (const auto* endpoint = FindProperty(properties, S3Properties::kEndpoint);
+      endpoint != nullptr) {
+    options.endpoint_override = *endpoint;
+  } else {
+    // Fall back to AWS standard environment variables for endpoint override
+    const char* s3_endpoint_env = std::getenv("AWS_ENDPOINT_URL_S3");
+    if (s3_endpoint_env != nullptr) {
+      options.endpoint_override = s3_endpoint_env;
+    } else {
+      const char* endpoint_env = std::getenv("AWS_ENDPOINT_URL");
+      if (endpoint_env != nullptr) {
+        options.endpoint_override = endpoint_env;
+      }
+    }
+  }
+
+  ICEBERG_ASSIGN_OR_RAISE(const auto path_style_access,
+                          ParseOptionalBool(properties, S3Properties::kPathStyleAccess));
+  if (path_style_access.has_value()) {
+    options.force_virtual_addressing = !*path_style_access;
+  }
+
+  // Configure SSL
+  ICEBERG_ASSIGN_OR_RAISE(const auto ssl_enabled,
+                          ParseOptionalBool(properties, S3Properties::kSslEnabled));
+  if (ssl_enabled.has_value() && !*ssl_enabled) {
+    options.scheme = "http";
+  }
+
+  // Configure timeouts
+  auto connect_timeout_it = properties.find(std::string(S3Properties::kConnectTimeoutMs));
+  if (connect_timeout_it != properties.end()) {
+    ICEBERG_ASSIGN_OR_RAISE(auto timeout_ms,
+                            StringUtils::ParseNumber<double>(connect_timeout_it->second));
+    options.connect_timeout = timeout_ms / 1000.0;
+  }
+
+  auto socket_timeout_it = properties.find(std::string(S3Properties::kSocketTimeoutMs));
+  if (socket_timeout_it != properties.end()) {
+    ICEBERG_ASSIGN_OR_RAISE(auto timeout_ms,
+                            StringUtils::ParseNumber<double>(socket_timeout_it->second));
+    options.request_timeout = timeout_ms / 1000.0;
+  }
+
+  return options;
+}
+#endif
+
+}  // namespace
+
+Result<std::unique_ptr<FileIO>> MakeS3FileIO(
+    const std::unordered_map<std::string, std::string>& properties) {
+#if ICEBERG_S3_ENABLED
+  ICEBERG_RETURN_UNEXPECTED(EnsureS3Initialized());
+
+  // Configure S3 options from properties (uses default credentials if empty)
+  ICEBERG_ASSIGN_OR_RAISE(auto options, ConfigureS3Options(properties));
+  ICEBERG_ARROW_ASSIGN_OR_RETURN(auto fs, ::arrow::fs::S3FileSystem::Make(options));
+
+  return std::make_unique<ArrowFileSystemFileIO>(std::move(fs));
+#else
+  return NotSupported("Arrow S3 support is not enabled");
+#endif
+}
+
+Status FinalizeS3() {
+#if ICEBERG_S3_ENABLED
+  auto status = ::arrow::fs::FinalizeS3();
+  ICEBERG_ARROW_RETURN_NOT_OK(status);
+  return {};
+#else
+  return NotSupported("Arrow S3 support is not enabled");
+#endif
+}
+
+}  // namespace iceberg::arrow
diff --git a/src/iceberg/arrow/s3/s3_properties.h b/src/iceberg/arrow/s3/s3_properties.h
new file mode 100644
index 00000000..53657743
--- /dev/null
+++ b/src/iceberg/arrow/s3/s3_properties.h
@@ -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.
+ */
+
+#pragma once
+
+#include <string_view>
+
+namespace iceberg::arrow {
+
+/// \brief S3 configuration property keys for ArrowS3FileIO.
+///
+/// These constants define the property keys used to configure S3 access
+/// via the Arrow filesystem integration, following the Iceberg spec for
+/// S3 configuration properties.
+struct S3Properties {
+  /// S3 URI scheme
+  static constexpr std::string_view kS3Schema = "s3";
+  /// AWS access key ID
+  static constexpr std::string_view kAccessKeyId = "s3.access-key-id";
+  /// AWS secret access key
+  static constexpr std::string_view kSecretAccessKey = "s3.secret-access-key";
+  /// AWS session token (for temporary credentials)
+  static constexpr std::string_view kSessionToken = "s3.session-token";
+  /// AWS region
+  static constexpr std::string_view kRegion = "s3.region";
+  /// Custom endpoint override (for MinIO, LocalStack, etc.)
+  static constexpr std::string_view kEndpoint = "s3.endpoint";
+  /// Whether to use path-style access (needed for MinIO)
+  static constexpr std::string_view kPathStyleAccess = "s3.path-style-access";
+  /// Whether SSL is enabled
+  static constexpr std::string_view kSslEnabled = "s3.ssl.enabled";
+  /// Connection timeout in milliseconds
+  static constexpr std::string_view kConnectTimeoutMs = "s3.connect-timeout-ms";
+  /// Socket timeout in milliseconds
+  static constexpr std::string_view kSocketTimeoutMs = "s3.socket-timeout-ms";
+};
+
+}  // namespace iceberg::arrow
diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt
index e91b1296..b862bc86 100644
--- a/src/iceberg/catalog/rest/CMakeLists.txt
+++ b/src/iceberg/catalog/rest/CMakeLists.txt
@@ -30,6 +30,7 @@ set(ICEBERG_REST_SOURCES
     json_serde.cc
     resource_paths.cc
     rest_catalog.cc
+    rest_file_io.cc
     rest_util.cc
     types.cc)
 
diff --git a/src/iceberg/catalog/rest/catalog_properties.h b/src/iceberg/catalog/rest/catalog_properties.h
index 41d7da79..0515926c 100644
--- a/src/iceberg/catalog/rest/catalog_properties.h
+++ b/src/iceberg/catalog/rest/catalog_properties.h
@@ -47,6 +47,8 @@ class ICEBERG_REST_EXPORT RestCatalogProperties
   inline static Entry<std::string> kName{"name", ""};
   /// \brief The warehouse path.
   inline static Entry<std::string> kWarehouse{"warehouse", ""};
+  /// \brief The FileIO implementation name.
+  inline static Entry<std::string> kIOImpl{"io-impl", ""};
   /// \brief The optional prefix for REST API paths.
   inline static Entry<std::string> kPrefix{"prefix", ""};
   /// \brief The encoded separator used to join namespace levels in REST paths.
diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build
index ef250045..a1f8ce97 100644
--- a/src/iceberg/catalog/rest/meson.build
+++ b/src/iceberg/catalog/rest/meson.build
@@ -28,6 +28,7 @@ iceberg_rest_sources = files(
     'json_serde.cc',
     'resource_paths.cc',
     'rest_catalog.cc',
+    'rest_file_io.cc',
     'rest_util.cc',
     'types.cc',
 )
@@ -71,6 +72,7 @@ install_headers(
         'iceberg_rest_export.h',
         'resource_paths.h',
         'rest_catalog.h',
+        'rest_file_io.h',
         'rest_util.h',
         'type_fwd.h',
         'types.h',
diff --git a/src/iceberg/catalog/rest/rest_catalog.cc b/src/iceberg/catalog/rest/rest_catalog.cc
index ebb03bf8..a0267adc 100644
--- a/src/iceberg/catalog/rest/rest_catalog.cc
+++ b/src/iceberg/catalog/rest/rest_catalog.cc
@@ -34,6 +34,7 @@
 #include "iceberg/catalog/rest/http_client.h"
 #include "iceberg/catalog/rest/json_serde_internal.h"
 #include "iceberg/catalog/rest/resource_paths.h"
+#include "iceberg/catalog/rest/rest_file_io.h"
 #include "iceberg/catalog/rest/rest_util.h"
 #include "iceberg/catalog/rest/types.h"
 #include "iceberg/json_serde_internal.h"
@@ -121,11 +122,8 @@ Result<bool> CaptureNoSuchNamespace(const auto& status) {
 RestCatalog::~RestCatalog() = default;
 
 Result<std::shared_ptr<RestCatalog>> RestCatalog::Make(
-    const RestCatalogProperties& config, std::shared_ptr<FileIO> file_io) {
+    const RestCatalogProperties& config) {
   ICEBERG_ASSIGN_OR_RAISE(auto uri, config.Uri());
-  if (!file_io) {
-    return InvalidArgument("FileIO is required to create RestCatalog");
-  }
 
   std::string catalog_name = config.Get(RestCatalogProperties::kName);
   ICEBERG_ASSIGN_OR_RAISE(auto auth_manager,
@@ -172,6 +170,9 @@ Result<std::shared_ptr<RestCatalog>> RestCatalog::Make(
   ICEBERG_ASSIGN_OR_RAISE(auto catalog_session,
                           auth_manager->CatalogSession(*client, final_config.configs()));
 
+  // Create FileIO with the final configuration
+  ICEBERG_ASSIGN_OR_RAISE(auto file_io, MakeCatalogFileIO(final_config));
+
   return std::shared_ptr<RestCatalog>(
       new RestCatalog(std::move(final_config), std::move(file_io), std::move(client),
                       std::move(paths), std::move(endpoints), std::move(auth_manager),
@@ -473,6 +474,7 @@ Result<std::shared_ptr<Table>> RestCatalog::LoadTable(const TableIdentifier& ide
   ICEBERG_ASSIGN_OR_RAISE(const auto body, LoadTableInternal(identifier));
   ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(body));
   ICEBERG_ASSIGN_OR_RAISE(auto load_result, LoadTableResultFromJson(json));
+  /// FIXME: support per-table FileIO creation
   return Table::Make(identifier, std::move(load_result.metadata),
                      std::move(load_result.metadata_location), file_io_,
                      shared_from_this());
diff --git a/src/iceberg/catalog/rest/rest_catalog.h b/src/iceberg/catalog/rest/rest_catalog.h
index 38230a5e..4fd4db5b 100644
--- a/src/iceberg/catalog/rest/rest_catalog.h
+++ b/src/iceberg/catalog/rest/rest_catalog.h
@@ -46,13 +46,8 @@ class ICEBERG_REST_EXPORT RestCatalog : public Catalog,
   RestCatalog(RestCatalog&&) = delete;
   RestCatalog& operator=(RestCatalog&&) = delete;
 
-  /// \brief Create a RestCatalog instance
-  ///
-  /// \param config the configuration for the RestCatalog
-  /// \param file_io the FileIO instance to use for table operations
-  /// \return a shared_ptr to RestCatalog instance
-  static Result<std::shared_ptr<RestCatalog>> Make(const RestCatalogProperties& config,
-                                                   std::shared_ptr<FileIO> file_io);
+  /// \brief Create a RestCatalog instance.
+  static Result<std::shared_ptr<RestCatalog>> Make(const RestCatalogProperties& config);
 
   std::string_view name() const override;
 
diff --git a/src/iceberg/catalog/rest/rest_file_io.cc b/src/iceberg/catalog/rest/rest_file_io.cc
new file mode 100644
index 00000000..f08a0335
--- /dev/null
+++ b/src/iceberg/catalog/rest/rest_file_io.cc
@@ -0,0 +1,95 @@
+/*
+ * 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.
+ */
+
+#include "iceberg/catalog/rest/rest_file_io.h"
+
+#include <string>
+
+#include "iceberg/file_io_registry.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg::rest {
+
+namespace {
+
+bool IsBuiltinImpl(std::string_view io_impl) {
+  return io_impl == FileIORegistry::kArrowLocalFileIO ||
+         io_impl == FileIORegistry::kArrowS3FileIO;
+}
+
+}  // namespace
+
+Result<BuiltinFileIOKind> DetectBuiltinFileIO(std::string_view location) {
+  const auto pos = location.find("://");
+  if (pos == std::string_view::npos) {
+    return BuiltinFileIOKind::kArrowLocal;
+  }
+
+  const auto scheme = location.substr(0, pos);
+  if (scheme == "file") {
+    return BuiltinFileIOKind::kArrowLocal;
+  }
+  if (scheme == "s3") {
+    return BuiltinFileIOKind::kArrowS3;
+  }
+
+  return NotSupported("URI scheme '{}' is not supported for automatic FileIO resolution",
+                      scheme);
+}
+
+std::string_view BuiltinFileIOName(BuiltinFileIOKind kind) {
+  switch (kind) {
+    case BuiltinFileIOKind::kArrowLocal:
+      return FileIORegistry::kArrowLocalFileIO;
+    case BuiltinFileIOKind::kArrowS3:
+      return FileIORegistry::kArrowS3FileIO;
+  }
+  std::unreachable();
+}
+
+Result<std::unique_ptr<FileIO>> MakeCatalogFileIO(const RestCatalogProperties& config) {
+  std::string io_impl = config.Get(RestCatalogProperties::kIOImpl);
+  std::string warehouse = config.Get(RestCatalogProperties::kWarehouse);
+
+  if (io_impl.empty()) {
+    if (warehouse.empty()) {
+      return InvalidArgument(R"("{}" or "{}" property is required to create FileIO)",
+                             RestCatalogProperties::kIOImpl.key(),
+                             RestCatalogProperties::kWarehouse.key());
+    }
+    ICEBERG_ASSIGN_OR_RAISE(const auto detected_kind, DetectBuiltinFileIO(warehouse));
+    io_impl = std::string(BuiltinFileIOName(detected_kind));
+  }
+
+  if (!warehouse.empty() && IsBuiltinImpl(io_impl)) {
+    ICEBERG_ASSIGN_OR_RAISE(const auto detected_kind, DetectBuiltinFileIO(warehouse));
+    const auto detected_name = BuiltinFileIOName(detected_kind);
+    if (io_impl != detected_name) {
+      return InvalidArgument(
+          R"("io-impl" value '{}' is incompatible with warehouse '{}')", io_impl,
+          warehouse);
+    }
+  }
+
+  // TODO(gangwu): Support Java-style customized FileIO creation flows instead of
+  // resolving a single catalog-scoped FileIO instance only from properties.
+  return FileIORegistry::Load(io_impl, config.configs());
+}
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/arrow/arrow_file_io.h b/src/iceberg/catalog/rest/rest_file_io.h
similarity index 57%
copy from src/iceberg/arrow/arrow_file_io.h
copy to src/iceberg/catalog/rest/rest_file_io.h
index 12a9b230..68482521 100644
--- a/src/iceberg/arrow/arrow_file_io.h
+++ b/src/iceberg/catalog/rest/rest_file_io.h
@@ -19,15 +19,29 @@
 
 #pragma once
 
+#include <cstdint>
 #include <memory>
+#include <string_view>
 
+#include "iceberg/catalog/rest/catalog_properties.h"
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
 #include "iceberg/file_io.h"
-#include "iceberg/iceberg_bundle_export.h"
+#include "iceberg/file_io_registry.h"
+#include "iceberg/result.h"
 
-namespace iceberg::arrow {
+namespace iceberg::rest {
 
-ICEBERG_BUNDLE_EXPORT std::unique_ptr<FileIO> MakeMockFileIO();
+enum class BuiltinFileIOKind : uint8_t {
+  kArrowLocal,
+  kArrowS3,
+};
 
-ICEBERG_BUNDLE_EXPORT std::unique_ptr<FileIO> MakeLocalFileIO();
+ICEBERG_REST_EXPORT Result<BuiltinFileIOKind> DetectBuiltinFileIO(
+    std::string_view location);
 
-}  // namespace iceberg::arrow
+ICEBERG_REST_EXPORT std::string_view BuiltinFileIOName(BuiltinFileIOKind kind);
+
+ICEBERG_REST_EXPORT Result<std::unique_ptr<FileIO>> MakeCatalogFileIO(
+    const RestCatalogProperties& config);
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/file_io_registry.cc b/src/iceberg/file_io_registry.cc
new file mode 100644
index 00000000..ffba8677
--- /dev/null
+++ b/src/iceberg/file_io_registry.cc
@@ -0,0 +1,61 @@
+// 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.
+
+#include "iceberg/file_io_registry.h"
+
+#include <mutex>
+#include <utility>
+
+namespace iceberg {
+
+namespace {
+
+struct RegistryState {
+  std::mutex mutex;
+  std::unordered_map<std::string, FileIORegistry::Factory> registry;
+};
+
+RegistryState& State() {
+  static RegistryState state;
+  return state;
+}
+
+}  // namespace
+
+void FileIORegistry::Register(const std::string& name, Factory factory) {
+  auto& state = State();
+  std::lock_guard lock(state.mutex);
+  state.registry[name] = std::move(factory);
+}
+
+Result<std::unique_ptr<FileIO>> FileIORegistry::Load(
+    const std::string& name,
+    const std::unordered_map<std::string, std::string>& properties) {
+  Factory factory;
+  {
+    auto& state = State();
+    std::lock_guard lock(state.mutex);
+    auto it = state.registry.find(name);
+    if (it == state.registry.end()) {
+      return NotFound("FileIO implementation not found: {}", name);
+    }
+    factory = it->second;
+  }
+  return factory(properties);
+}
+
+}  // namespace iceberg
diff --git a/src/iceberg/file_io_registry.h b/src/iceberg/file_io_registry.h
new file mode 100644
index 00000000..a0be1b56
--- /dev/null
+++ b/src/iceberg/file_io_registry.h
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+#include "iceberg/file_io.h"
+#include "iceberg/iceberg_export.h"
+#include "iceberg/result.h"
+
+namespace iceberg {
+
+/// \brief Registry for FileIO implementations.
+///
+/// Provides a mechanism to register and load FileIO implementations by name.
+/// This allows the REST catalog (and others) to resolve FileIO implementations
+/// at runtime based on configuration properties like "io-impl".
+class ICEBERG_EXPORT FileIORegistry {
+ public:
+  static constexpr std::string_view kArrowLocalFileIO = "arrow-fs-local";
+  static constexpr std::string_view kArrowS3FileIO = "arrow-fs-s3";
+
+  /// Factory function type for creating FileIO instances.
+  using Factory = std::function<Result<std::unique_ptr<FileIO>>(
+      const std::unordered_map<std::string, std::string>& properties)>;
+
+  /// \brief Register a FileIO factory under the given name.
+  ///
+  /// \param name The implementation name (e.g., "local", "s3")
+  /// \param factory The factory function that creates the FileIO instance.
+  static void Register(const std::string& name, Factory factory);
+
+  /// \brief Load a FileIO implementation by name.
+  ///
+  /// \param name The implementation name to look up.
+  /// \param properties Configuration properties to pass to the factory.
+  /// \return A unique_ptr to the FileIO instance, or an error if not found.
+  static Result<std::unique_ptr<FileIO>> Load(
+      const std::string& name,
+      const std::unordered_map<std::string, std::string>& properties);
+};
+
+}  // namespace iceberg
diff --git a/src/iceberg/meson.build b/src/iceberg/meson.build
index 99ac01f6..1e6953c8 100644
--- a/src/iceberg/meson.build
+++ b/src/iceberg/meson.build
@@ -65,6 +65,7 @@ iceberg_sources = files(
     'expression/rewrite_not.cc',
     'expression/strict_metrics_evaluator.cc',
     'expression/term.cc',
+    'file_io_registry.cc',
     'file_reader.cc',
     'file_writer.cc',
     'inheritable_metadata.cc',
@@ -148,8 +149,8 @@ iceberg_sources = files(
 # CRoaring does not export symbols, so on Windows it must
 # be used as a static lib
 croaring_needs_static = (
-    get_option('default_library') == 'static' or
-    host_machine.system() == 'windows'
+    get_option('default_library') == 'static'
+    or host_machine.system() == 'windows'
 )
 croaring_dep = dependency('croaring', static: croaring_needs_static)
 nanoarrow_dep = dependency('nanoarrow')
@@ -193,6 +194,7 @@ install_headers(
         'exception.h',
         'file_format.h',
         'file_io.h',
+        'file_io_registry.h',
         'file_reader.h',
         'file_writer.h',
         'iceberg_export.h',
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 2dc90da6..58edcf7e 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -145,6 +145,10 @@ if(ICEBERG_BUILD_BUNDLE)
                    metadata_io_test.cc
                    struct_like_test.cc)
 
+  if(ICEBERG_S3)
+    add_iceberg_test(file_io_test USE_BUNDLE SOURCES arrow_s3_file_io_test.cc)
+  endif()
+
   add_iceberg_test(catalog_test USE_BUNDLE SOURCES in_memory_catalog_test.cc)
 
   add_iceberg_test(eval_expr_test
@@ -227,6 +231,7 @@ if(ICEBERG_BUILD_REST)
                         SOURCES
                         auth_manager_test.cc
                         endpoint_test.cc
+                        rest_file_io_test.cc
                         rest_json_serde_test.cc
                         rest_util_test.cc)
 
diff --git a/src/iceberg/test/arrow_s3_file_io_test.cc b/src/iceberg/test/arrow_s3_file_io_test.cc
new file mode 100644
index 00000000..d890ad10
--- /dev/null
+++ b/src/iceberg/test/arrow_s3_file_io_test.cc
@@ -0,0 +1,184 @@
+/*
+ * 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.
+ */
+
+#include <cstdlib>
+#include <iostream>
+#include <optional>
+#include <string>
+#include <unordered_map>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include "iceberg/arrow/arrow_file_io.h"
+#include "iceberg/arrow/s3/s3_properties.h"
+#include "iceberg/test/matchers.h"
+
+namespace {
+
+std::optional<std::string> GetEnvIfSet(const char* key) {
+  const char* value = std::getenv(key);
+  if (value == nullptr || std::string_view(value).empty()) {
+    return std::nullopt;
+  }
+  return std::string(value);
+}
+
+std::string MakeObjectUri(std::string_view base_uri, std::string_view object_name) {
+  std::string object_uri(base_uri);
+  if (!object_uri.ends_with('/')) {
+    object_uri += '/';
+  }
+  object_uri += object_name;
+  return object_uri;
+}
+
+std::unordered_map<std::string, std::string> PropertiesFromEnv() {
+  std::unordered_map<std::string, std::string> properties;
+
+  if (const auto access_key = GetEnvIfSet("AWS_ACCESS_KEY_ID")) {
+    properties[std::string(iceberg::arrow::S3Properties::kAccessKeyId)] = *access_key;
+  }
+  if (const auto secret_key = GetEnvIfSet("AWS_SECRET_ACCESS_KEY")) {
+    properties[std::string(iceberg::arrow::S3Properties::kSecretAccessKey)] = *secret_key;
+  }
+  if (const auto endpoint = GetEnvIfSet("ICEBERG_TEST_S3_ENDPOINT")) {
+    properties[std::string(iceberg::arrow::S3Properties::kEndpoint)] = *endpoint;
+  }
+  if (const auto region = GetEnvIfSet("AWS_REGION")) {
+    properties[std::string(iceberg::arrow::S3Properties::kRegion)] = *region;
+  }
+
+  return properties;
+}
+
+}  // namespace
+
+namespace iceberg::arrow {
+
+namespace {
+
+class ArrowS3FileIOTest : public ::testing::Test {
+ protected:
+  static void TearDownTestSuite() {
+    auto status = FinalizeS3();
+    if (!status.has_value()) {
+      std::cerr << "Warning: FinalizeS3 failed: " << status.error().message << std::endl;
+    }
+  }
+
+  void SetUp() override { base_uri_ = GetEnvIfSet("ICEBERG_TEST_S3_URI"); }
+
+  std::string ObjectUri(std::string_view object_name) const {
+    return MakeObjectUri(*base_uri_, object_name);
+  }
+
+  void RequireIntegrationEnv() const {
+    if (!base_uri_.has_value()) {
+      GTEST_SKIP() << "Set ICEBERG_TEST_S3_URI to enable S3 IO test";
+    }
+  }
+
+ private:
+  std::optional<std::string> base_uri_;
+};
+
+}  // namespace
+
+TEST_F(ArrowS3FileIOTest, CreateWithDefaultProperties) {
+  auto result = MakeS3FileIO({});
+  ASSERT_THAT(result, IsOk());
+  EXPECT_NE(result.value(), nullptr);
+}
+
+TEST_F(ArrowS3FileIOTest, RequiresS3SupportAtBuildTime) {
+  auto result = MakeS3FileIO();
+  ASSERT_THAT(result, IsOk());
+}
+
+TEST_F(ArrowS3FileIOTest, RejectsIncompleteStaticCredentials) {
+  auto result =
+      MakeS3FileIO({{std::string(S3Properties::kAccessKeyId), "access-key-only"}});
+  EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+  EXPECT_THAT(result, HasErrorMessage(
+                          "S3 client access key ID and secret access key must be set"));
+}
+
+TEST_F(ArrowS3FileIOTest, RejectsInvalidBooleanProperties) {
+  auto result =
+      MakeS3FileIO({{std::string(S3Properties::kPathStyleAccess), "not-a-bool"}});
+  EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST_F(ArrowS3FileIOTest, ReadWriteFile) {
+  RequireIntegrationEnv();
+  auto io_res = MakeS3FileIO();
+  ASSERT_THAT(io_res, IsOk());
+  auto io = std::move(io_res).value();
+
+  auto object_uri = ObjectUri("iceberg_s3_io_test.txt");
+  auto write_res = io->WriteFile(object_uri, "hello s3");
+  ASSERT_THAT(write_res, IsOk());
+
+  auto read_res = io->ReadFile(object_uri, std::nullopt);
+  ASSERT_THAT(read_res, IsOk());
+  EXPECT_THAT(read_res, HasValue(::testing::Eq("hello s3")));
+
+  auto del_res = io->DeleteFile(object_uri);
+  EXPECT_THAT(del_res, IsOk());
+}
+
+TEST_F(ArrowS3FileIOTest, MakeS3FileIOWithProperties) {
+  RequireIntegrationEnv();
+  auto io_res = MakeS3FileIO(PropertiesFromEnv());
+  ASSERT_THAT(io_res, IsOk());
+  auto io = std::move(io_res).value();
+
+  auto object_uri = ObjectUri("iceberg_s3_io_props_test.txt");
+  auto write_res = io->WriteFile(object_uri, "hello s3 with properties");
+  ASSERT_THAT(write_res, IsOk());
+
+  auto read_res = io->ReadFile(object_uri, std::nullopt);
+  ASSERT_THAT(read_res, IsOk());
+  EXPECT_THAT(read_res, HasValue(::testing::Eq("hello s3 with properties")));
+
+  auto del_res = io->DeleteFile(object_uri);
+  EXPECT_THAT(del_res, IsOk());
+}
+
+TEST_F(ArrowS3FileIOTest, MakeS3FileIOWithSslDisabled) {
+  RequireIntegrationEnv();
+  std::unordered_map<std::string, std::string> properties;
+  properties[std::string(S3Properties::kSslEnabled)] = "false";
+
+  auto io_res = MakeS3FileIO(properties);
+  ASSERT_THAT(io_res, IsOk());
+}
+
+TEST_F(ArrowS3FileIOTest, MakeS3FileIOWithTimeouts) {
+  RequireIntegrationEnv();
+  std::unordered_map<std::string, std::string> properties;
+  properties[std::string(S3Properties::kConnectTimeoutMs)] = "5000";
+  properties[std::string(S3Properties::kSocketTimeoutMs)] = "10000";
+
+  auto io_res = MakeS3FileIO(properties);
+  ASSERT_THAT(io_res, IsOk());
+}
+
+}  // namespace iceberg::arrow
diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build
index 9a8da9dd..9e6b1680 100644
--- a/src/iceberg/test/meson.build
+++ b/src/iceberg/test/meson.build
@@ -110,6 +110,7 @@ if get_option('rest').enabled()
             'sources': files(
                 'auth_manager_test.cc',
                 'endpoint_test.cc',
+                'rest_file_io_test.cc',
                 'rest_json_serde_test.cc',
                 'rest_util_test.cc',
             ),
diff --git a/src/iceberg/test/rest_catalog_integration_test.cc b/src/iceberg/test/rest_catalog_integration_test.cc
index b364ffd3..3de7e722 100644
--- a/src/iceberg/test/rest_catalog_integration_test.cc
+++ b/src/iceberg/test/rest_catalog_integration_test.cc
@@ -39,6 +39,7 @@
 #include "iceberg/catalog/rest/http_client.h"
 #include "iceberg/catalog/rest/json_serde_internal.h"
 #include "iceberg/catalog/rest/rest_catalog.h"
+#include "iceberg/file_io_registry.h"
 #include "iceberg/partition_spec.h"
 #include "iceberg/result.h"
 #include "iceberg/schema.h"
@@ -65,6 +66,7 @@ constexpr std::string_view kDockerProjectName = "iceberg-rest-catalog-service";
 constexpr std::string_view kCatalogName = "test_catalog";
 constexpr std::string_view kWarehouseName = "default";
 constexpr std::string_view kLocalhostUri = "http://localhost";
+constexpr std::string_view kStdFileIOImpl = "test.StdFileIO";
 
 /// \brief Check if a localhost port is ready to accept connections.
 bool CheckServiceReady(uint16_t port) {
@@ -96,6 +98,12 @@ std::string CatalogUri() { return std::format("{}:{}", kLocalhostUri, kRestCatal
 class RestCatalogIntegrationTest : public ::testing::Test {
  protected:
   static void SetUpTestSuite() {
+    FileIORegistry::Register(
+        std::string(kStdFileIOImpl),
+        [](const std::unordered_map<std::string, std::string>& /*properties*/)
+            -> Result<std::unique_ptr<FileIO>> {
+          return std::make_unique<test::StdFileIO>();
+        });
     docker_compose_ = std::make_unique<DockerCompose>(
         std::string{kDockerProjectName}, GetResourcePath("iceberg-rest-fixture"));
     docker_compose_->Up();
@@ -126,10 +134,12 @@ class RestCatalogIntegrationTest : public ::testing::Test {
     config.Set(RestCatalogProperties::kUri, CatalogUri())
         .Set(RestCatalogProperties::kName, std::string(kCatalogName))
         .Set(RestCatalogProperties::kWarehouse, std::string(kWarehouseName));
+    config.mutable_configs()[std::string(RestCatalogProperties::kIOImpl.key())] =
+        std::string(kStdFileIOImpl);
     for (const auto& [k, v] : extra) {
       config.mutable_configs()[k] = v;
     }
-    return RestCatalog::Make(config, std::make_shared<test::StdFileIO>());
+    return RestCatalog::Make(config);
   }
 
   /// Create a catalog configured with a specific snapshot loading mode.
diff --git a/src/iceberg/test/rest_file_io_test.cc b/src/iceberg/test/rest_file_io_test.cc
new file mode 100644
index 00000000..e9131da3
--- /dev/null
+++ b/src/iceberg/test/rest_file_io_test.cc
@@ -0,0 +1,144 @@
+// 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.
+
+#include "iceberg/catalog/rest/rest_file_io.h"
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include "iceberg/file_io_registry.h"
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::rest {
+
+namespace {
+
+class MockFileIO : public FileIO {
+ public:
+  Result<std::string> ReadFile(const std::string& /*file_location*/,
+                               std::optional<size_t> /*length*/) override {
+    return std::string("mock");
+  }
+
+  Status WriteFile(const std::string& /*file_location*/,
+                   std::string_view /*content*/) override {
+    return {};
+  }
+
+  Status DeleteFile(const std::string& /*file_location*/) override { return {}; }
+};
+
+}  // namespace
+
+TEST(RestFileIOTest, DetectBuiltinKindFromScheme) {
+  EXPECT_THAT(DetectBuiltinFileIO("s3://bucket/path"),
+              HasValue(::testing::Eq(BuiltinFileIOKind::kArrowS3)));
+  EXPECT_THAT(DetectBuiltinFileIO("/tmp/warehouse"),
+              HasValue(::testing::Eq(BuiltinFileIOKind::kArrowLocal)));
+  EXPECT_THAT(DetectBuiltinFileIO("file:///tmp/warehouse"),
+              HasValue(::testing::Eq(BuiltinFileIOKind::kArrowLocal)));
+}
+
+TEST(RestFileIOTest, DetectBuiltinKindRejectsUnsupportedScheme) {
+  auto result = DetectBuiltinFileIO("gs://bucket/warehouse");
+  EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
+  EXPECT_THAT(result, HasErrorMessage("not supported for automatic FileIO resolution"));
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIOMissingImplAndWarehouse) {
+  auto result = MakeCatalogFileIO(RestCatalogProperties::default_properties());
+  EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIORejectsIncompatibleWarehouse) {
+  FileIORegistry::Register(
+      std::string(FileIORegistry::kArrowS3FileIO),
+      [](const std::unordered_map<std::string, std::string>& /*properties*/)
+          -> Result<std::unique_ptr<FileIO>> { return std::make_unique<MockFileIO>(); });
+
+  auto config = RestCatalogProperties::FromMap(
+      {{"io-impl", std::string(FileIORegistry::kArrowS3FileIO)},
+       {"warehouse", "/tmp/warehouse"}});
+  auto result = MakeCatalogFileIO(config);
+  EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+  EXPECT_THAT(result, HasErrorMessage("incompatible"));
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIOAutoDetectsFromWarehouse) {
+  FileIORegistry::Register(
+      std::string(FileIORegistry::kArrowLocalFileIO),
+      [](const std::unordered_map<std::string, std::string>& /*properties*/)
+          -> Result<std::unique_ptr<FileIO>> { return std::make_unique<MockFileIO>(); });
+
+  auto config = RestCatalogProperties::FromMap({{"warehouse", "/tmp/warehouse"}});
+  auto result = MakeCatalogFileIO(config);
+  ASSERT_THAT(result, IsOk());
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIORejectsUnsupportedWarehouseScheme) {
+  auto config = RestCatalogProperties::FromMap({{"warehouse", "gs://bucket/warehouse"}});
+  auto result = MakeCatalogFileIO(config);
+  EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
+  EXPECT_THAT(result, HasErrorMessage("not supported for automatic FileIO resolution"));
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIOAllowsCompatibleWarehouse) {
+  FileIORegistry::Register(
+      std::string(FileIORegistry::kArrowS3FileIO),
+      [](const std::unordered_map<std::string, std::string>& /*properties*/)
+          -> Result<std::unique_ptr<FileIO>> { return std::make_unique<MockFileIO>(); });
+
+  auto config = RestCatalogProperties::FromMap(
+      {{"io-impl", std::string(FileIORegistry::kArrowS3FileIO)},
+       {"warehouse", "s3://my-bucket/warehouse"}});
+  auto result = MakeCatalogFileIO(config);
+  ASSERT_THAT(result, IsOk());
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIOPassesThroughCustomImpl) {
+  const std::string custom_impl = "com.mycompany.CustomFileIO";
+  FileIORegistry::Register(
+      custom_impl,
+      [](const std::unordered_map<std::string, std::string>& /*properties*/)
+          -> Result<std::unique_ptr<FileIO>> { return std::make_unique<MockFileIO>(); });
+
+  auto config = RestCatalogProperties::FromMap(
+      {{"io-impl", custom_impl}, {"warehouse", "/tmp/warehouse"}});
+  auto result = MakeCatalogFileIO(config);
+  ASSERT_THAT(result, IsOk());
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIOUnregisteredCustomImplReturnsNotFound) {
+  auto config = RestCatalogProperties::FromMap(
+      {{"io-impl", "com.nonexistent.FileIO"}, {"warehouse", "/tmp/warehouse"}});
+  auto result = MakeCatalogFileIO(config);
+  EXPECT_THAT(result, IsError(ErrorKind::kNotFound));
+}
+
+TEST(RestFileIOTest, MakeCatalogFileIOSkipsCheckWhenWarehouseAbsent) {
+  FileIORegistry::Register(
+      std::string(FileIORegistry::kArrowLocalFileIO),
+      [](const std::unordered_map<std::string, std::string>& /*properties*/)
+          -> Result<std::unique_ptr<FileIO>> { return std::make_unique<MockFileIO>(); });
+
+  auto config = RestCatalogProperties::FromMap(
+      {{"io-impl", std::string(FileIORegistry::kArrowLocalFileIO)}});
+  auto result = MakeCatalogFileIO(config);
+  ASSERT_THAT(result, IsOk());
+}
+
+}  // namespace iceberg::rest