A Customizable find_files Function for parquet_s3_fdw¶
Objective: Build a Python helper that discovers Parquet files across local storage, MinIO, Vast, and AWS S3, then generates clean SQL CREATE FOREIGN TABLE statements for parquet_s3_fdw.
The pain of manually writing FDW table DDLs is real. This tutorial provides a machine that does the heavy liftingβdiscovering Parquet files and generating the SQL to expose them as foreign tables in Postgres.
1) The Problem: Manual Table Creation Hell¶
The Manual Way¶
-- The repetitive nightmare
CREATE FOREIGN TABLE my_table (
id bigint,
name text,
ts timestamptz
)
SERVER parquet_s3
OPTIONS (filename 's3://mybucket/data/my_table.parquet');
CREATE FOREIGN TABLE another_table (
id bigint,
name text,
ts timestamptz
)
SERVER parquet_s3
OPTIONS (filename 's3://mybucket/data/another_table.parquet');
-- And so on for hundreds of files...
Why This Sucks: Manual table creation doesn't scale. You're writing the same DDL over and over, making typos, and losing track of what tables exist.
The Automated Solution¶
# The machine's approach
files = find_files("s3://mybucket/data/", backend="s3")
sql = generate_fdw_sql(files, schema="analytics", server="parquet_s3")
print(sql)
Why This Works: One command discovers all Parquet files and generates the SQL. The machine handles the complexity, you get clean DDL.
2) Building the find_files Function¶
Core Implementation¶
import os
import pathlib
import boto3
from urllib.parse import urlparse
from typing import List, Dict, Optional
import logging
def find_files(
uri: str,
suffix: str = ".parquet",
recursive: bool = True,
backend: str = "local",
endpoint_url: Optional[str] = None
) -> List[Dict[str, str]]:
"""
Discover Parquet files for FDW table creation.
Args:
uri: Path or S3 URI (e.g., "s3://bucket/path" or "/local/path")
suffix: File extension to match (default: ".parquet")
recursive: Search subdirectories (default: True)
backend: Storage backend ("local", "s3", "minio", "vast")
endpoint_url: Custom S3 endpoint for MinIO/Vast
Returns:
List of dicts with 'table' and 'path' keys
"""
results = []
if backend == "local":
results = _find_local_files(uri, suffix, recursive)
elif backend in ("s3", "minio", "vast"):
results = _find_s3_files(uri, suffix, recursive, endpoint_url)
else:
raise ValueError(f"Unsupported backend: {backend}")
return results
def _find_local_files(uri: str, suffix: str, recursive: bool) -> List[Dict[str, str]]:
"""Find files in local filesystem."""
results = []
path = pathlib.Path(uri)
if recursive:
pattern = f"**/*{suffix}"
else:
pattern = f"*{suffix}"
for file_path in path.glob(pattern):
if file_path.is_file():
table_name = _sanitize_table_name(file_path.stem)
results.append({
"table": table_name,
"path": str(file_path.absolute())
})
return results
def _find_s3_files(
uri: str,
suffix: str,
recursive: bool,
endpoint_url: Optional[str]
) -> List[Dict[str, str]]:
"""Find files in S3-compatible storage."""
results = []
parsed = urlparse(uri, allow_fragments=False)
bucket = parsed.netloc
prefix = parsed.path.lstrip("/")
# Configure S3 client
s3_config = {}
if endpoint_url:
s3_config["endpoint_url"] = endpoint_url
s3 = boto3.client("s3", **s3_config)
# List objects
paginator = s3.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=bucket, Prefix=prefix)
for page in pages:
for obj in page.get("Contents", []):
key = obj["Key"]
# Filter by suffix
if not key.endswith(suffix):
continue
# Handle recursive vs non-recursive
if not recursive and "/" in key[len(prefix):]:
continue
table_name = _sanitize_table_name(pathlib.Path(key).stem)
s3_uri = f"s3://{bucket}/{key}"
results.append({
"table": table_name,
"path": s3_uri
})
return results
def _sanitize_table_name(name: str) -> str:
"""Convert filename to valid SQL table name."""
# Replace invalid characters with underscores
sanitized = "".join(c if c.isalnum() or c == "_" else "_" for c in name)
# Remove leading/trailing underscores
sanitized = sanitized.strip("_")
# Ensure it starts with letter or underscore
if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"):
sanitized = f"table_{sanitized}"
return sanitized or "unnamed_table"
Why This Works: The function handles multiple backends through a unified interface, sanitizes table names, and provides flexible search options.
3) Generating FDW Table DDLs¶
SQL Generation Function¶
def generate_fdw_sql(
files: List[Dict[str, str]],
schema: str = "public",
server: str = "parquet_s3",
table_prefix: str = "",
table_suffix: str = "",
use_dirpath: bool = False
) -> str:
"""
Generate CREATE FOREIGN TABLE statements from discovered files.
Args:
files: Output from find_files()
schema: Target schema name
server: FDW server name
table_prefix: Prefix for table names
table_suffix: Suffix for table names
use_dirpath: Use dirpath instead of filename for partitioned data
Returns:
SQL statements as string
"""
statements = []
for file_info in files:
table_name = f"{table_prefix}{file_info['table']}{table_suffix}"
full_table_name = f"{schema}.{table_name}"
# Determine options based on use_dirpath
if use_dirpath:
# Extract directory path for partitioned data
dir_path = "/".join(file_info['path'].split("/")[:-1])
options = f"dirpath '{dir_path}'"
else:
options = f"filename '{file_info['path']}'"
# Generate SQL
sql = f"""CREATE FOREIGN TABLE IF NOT EXISTS {full_table_name} (
-- Columns will be inferred by parquet_s3_fdw
-- or define explicitly based on your schema
)
SERVER {server}
OPTIONS ({options});"""
statements.append(sql)
return "\n\n".join(statements)
def generate_fdw_sql_with_schema(
files: List[Dict[str, str]],
schema: str = "public",
server: str = "parquet_s3",
column_definitions: Optional[Dict[str, str]] = None
) -> str:
"""
Generate FDW SQL with explicit column definitions.
Args:
files: Output from find_files()
schema: Target schema name
server: FDW server name
column_definitions: Dict mapping column names to SQL types
Returns:
SQL statements with explicit column definitions
"""
statements = []
for file_info in files:
table_name = file_info['table']
full_table_name = f"{schema}.{table_name}"
# Build column definitions
if column_definitions:
columns = ",\n ".join([f"{col} {type_}" for col, type_ in column_definitions.items()])
else:
columns = "-- Columns will be inferred by parquet_s3_fdw"
sql = f"""CREATE FOREIGN TABLE IF NOT EXISTS {full_table_name} (
{columns}
)
SERVER {server}
OPTIONS (filename '{file_info['path']}');"""
statements.append(sql)
return "\n\n".join(statements)
Why This Works: The generator creates clean, valid SQL that can be executed directly in Postgres. It handles both single files and partitioned directories.
4) One-Liner Workflow¶
Simple Usage¶
# Discover files and generate SQL
files = find_files("s3://mybucket/data/2025/", backend="s3")
sql = generate_fdw_sql(files, schema="raw", server="parquet_s3")
print(sql)
Output:
CREATE FOREIGN TABLE IF NOT EXISTS raw.data_2025_01 (
-- Columns will be inferred by parquet_s3_fdw
)
SERVER parquet_s3
OPTIONS (filename 's3://mybucket/data/2025/data_2025_01.parquet');
CREATE FOREIGN TABLE IF NOT EXISTS raw.data_2025_02 (
-- Columns will be inferred by parquet_s3_fdw
)
SERVER parquet_s3
OPTIONS (filename 's3://mybucket/data/2025/data_2025_02.parquet');
Advanced Usage¶
# Customized table naming and column definitions
files = find_files("s3://analytics-bucket/events/", backend="s3")
sql = generate_fdw_sql_with_schema(
files,
schema="analytics",
server="parquet_s3",
column_definitions={
"event_id": "bigint",
"user_id": "text",
"timestamp": "timestamptz",
"event_type": "text",
"properties": "jsonb"
}
)
print(sql)
Why This Scales: One command handles discovery, naming, and SQL generation. The machine does the repetitive work.
5) Customization & Extensions¶
Table Naming Conventions¶
def find_files_with_naming(
uri: str,
backend: str = "local",
name_template: str = "{prefix}_{table}_{suffix}",
prefix: str = "",
suffix: str = "",
strip_prefix: str = "",
strip_suffix: str = ""
) -> List[Dict[str, str]]:
"""Find files with custom naming conventions."""
files = find_files(uri, backend=backend)
for file_info in files:
table_name = file_info['table']
# Strip prefixes/suffixes
if strip_prefix and table_name.startswith(strip_prefix):
table_name = table_name[len(strip_prefix):]
if strip_suffix and table_name.endswith(strip_suffix):
table_name = table_name[:-len(strip_suffix)]
# Apply template
formatted_name = name_template.format(
prefix=prefix,
table=table_name,
suffix=suffix
)
file_info['table'] = formatted_name
return files
# Usage examples
files = find_files_with_naming(
"s3://bucket/data/",
backend="s3",
name_template="raw_{table}",
strip_prefix="data_",
strip_suffix="_processed"
)
Partitioned Data Handling¶
def find_partitioned_files(
uri: str,
backend: str = "s3",
partition_columns: List[str] = None
) -> List[Dict[str, str]]:
"""Find files in partitioned directories."""
files = find_files(uri, backend=backend)
# Group files by directory for dirpath usage
dirs = {}
for file_info in files:
dir_path = "/".join(file_info['path'].split("/")[:-1])
if dir_path not in dirs:
dirs[dir_path] = []
dirs[dir_path].append(file_info)
# Generate SQL for each partition
statements = []
for dir_path, dir_files in dirs.items():
table_name = _sanitize_table_name(pathlib.Path(dir_path).name)
sql = f"""CREATE FOREIGN TABLE IF NOT EXISTS public.{table_name} (
-- Columns will be inferred
)
SERVER parquet_s3
OPTIONS (dirpath '{dir_path}');"""
statements.append(sql)
return statements
Why This Matters: Customization handles real-world naming conventions and partitioned data structures.
6) Best Practices¶
Schema Consistency¶
def validate_schema_consistency(files: List[Dict[str, str]], backend: str = "s3") -> Dict[str, List[str]]:
"""Validate that all files have consistent schemas."""
import pyarrow.parquet as pq
schemas = {}
for file_info in files:
try:
if backend == "local":
schema = pq.read_schema(file_info['path'])
else:
# For S3, you'd need to download or use boto3
# This is a simplified example
schema = pq.read_schema(file_info['path'])
schema_str = str(schema)
if schema_str not in schemas:
schemas[schema_str] = []
schemas[schema_str].append(file_info['table'])
except Exception as e:
print(f"Warning: Could not read schema for {file_info['path']}: {e}")
return schemas
# Usage
files = find_files("s3://bucket/data/", backend="s3")
schemas = validate_schema_consistency(files)
if len(schemas) > 1:
print("Warning: Multiple schemas detected")
for schema, tables in schemas.items():
print(f"Schema: {schema[:100]}...")
print(f"Tables: {tables}")
Caching Results¶
import json
import hashlib
from pathlib import Path
def find_files_with_cache(
uri: str,
backend: str = "local",
cache_file: str = ".parquet_cache.json",
cache_ttl: int = 3600 # 1 hour
) -> List[Dict[str, str]]:
"""Find files with caching to avoid repeated S3 calls."""
cache_path = Path(cache_file)
# Generate cache key
cache_key = hashlib.md5(f"{uri}_{backend}".encode()).hexdigest()
# Check cache
if cache_path.exists():
with open(cache_path) as f:
cache = json.load(f)
if cache_key in cache:
cached_data = cache[cache_key]
if time.time() - cached_data['timestamp'] < cache_ttl:
return cached_data['files']
# Discover files
files = find_files(uri, backend=backend)
# Update cache
cache = {}
if cache_path.exists():
with open(cache_path) as f:
cache = json.load(f)
cache[cache_key] = {
'files': files,
'timestamp': time.time()
}
with open(cache_path, 'w') as f:
json.dump(cache, f)
return files
Why This Matters: Schema consistency prevents query errors, and caching improves performance for large buckets.
7) Full Example: MinIO with parquet_s3_fdw¶
Complete Workflow¶
# 1. Discover files in MinIO
files = find_files(
"s3://minio-bucket/analytics/",
backend="minio",
endpoint_url="http://localhost:9000"
)
# 2. Generate SQL with custom schema
sql = generate_fdw_sql_with_schema(
files,
schema="analytics",
server="parquet_minio",
column_definitions={
"id": "bigint",
"event_type": "text",
"timestamp": "timestamptz",
"user_id": "text",
"properties": "jsonb"
}
)
# 3. Save to file
with open("create_foreign_tables.sql", "w") as f:
f.write(sql)
print(f"Generated SQL for {len(files)} tables")
MinIO Configuration¶
# MinIO-specific configuration
def find_files_minio(
bucket: str,
prefix: str = "",
endpoint_url: str = "http://localhost:9000",
access_key: str = None,
secret_key: str = None
) -> List[Dict[str, str]]:
"""Find files in MinIO with proper authentication."""
import boto3
s3 = boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=access_key or os.getenv("MINIO_ACCESS_KEY"),
aws_secret_access_key=secret_key or os.getenv("MINIO_SECRET_KEY")
)
uri = f"s3://{bucket}/{prefix}"
return find_files(uri, backend="s3", endpoint_url=endpoint_url)
Postgres Setup¶
-- 1. Create FDW server for MinIO
CREATE SERVER parquet_minio
FOREIGN DATA WRAPPER parquet_s3_fdw
OPTIONS (
use_minio 'true',
aws_region 'us-east-1',
aws_access_key_id 'minio',
aws_secret_access_key 'minio123',
endpoint 'http://localhost:9000'
);
-- 2. Create schema
CREATE SCHEMA IF NOT EXISTS analytics;
-- 3. Execute generated SQL
\i create_foreign_tables.sql
-- 4. Verify tables
\dt analytics.*
Why This Works: The complete workflow handles discovery, SQL generation, and Postgres setup for MinIO.
8) Advanced Features¶
Multi-Backend Support¶
def find_files_multi_backend(
backends: List[Dict[str, str]]
) -> List[Dict[str, str]]:
"""Find files across multiple backends."""
all_files = []
for backend_config in backends:
files = find_files(
backend_config['uri'],
backend=backend_config['backend'],
endpoint_url=backend_config.get('endpoint_url')
)
# Add backend prefix to table names
for file_info in files:
file_info['table'] = f"{backend_config['prefix']}_{file_info['table']}"
all_files.extend(files)
return all_files
# Usage
backends = [
{
'uri': 's3://aws-bucket/data/',
'backend': 's3',
'prefix': 'aws'
},
{
'uri': 's3://minio-bucket/data/',
'backend': 'minio',
'endpoint_url': 'http://localhost:9000',
'prefix': 'minio'
}
]
files = find_files_multi_backend(backends)
Schema Inference¶
def infer_schema_from_files(files: List[Dict[str, str]], backend: str = "s3") -> Dict[str, str]:
"""Infer schema from Parquet files."""
import pyarrow.parquet as pq
schemas = {}
for file_info in files:
try:
if backend == "local":
schema = pq.read_schema(file_info['path'])
else:
# For S3, you'd need to download or use boto3
schema = pq.read_schema(file_info['path'])
# Convert Arrow schema to SQL types
sql_types = {}
for field in schema:
arrow_type = str(field.type)
if 'int' in arrow_type:
sql_types[field.name] = 'bigint'
elif 'string' in arrow_type:
sql_types[field.name] = 'text'
elif 'timestamp' in arrow_type:
sql_types[field.name] = 'timestamptz'
else:
sql_types[field.name] = 'text'
schemas[file_info['table']] = sql_types
except Exception as e:
print(f"Warning: Could not infer schema for {file_info['path']}: {e}")
return schemas
9) TL;DR Quickstart¶
# 1. Install dependencies
# pip install boto3 pyarrow
# 2. Discover files
files = find_files("s3://bucket/path", backend="s3")
# 3. Generate SQL
sql = generate_fdw_sql(files, schema="analytics", server="parquet_s3")
# 4. Save and execute
with open("tables.sql", "w") as f:
f.write(sql)
# 5. In Postgres
# \i tables.sql
10) The Machine's Summary¶
This find_files function eliminates the manual pain of creating foreign tables. It discovers Parquet files across multiple backends, generates clean SQL, and handles the complexity of naming and schema management.
The Dark Truth: Manual table creation doesn't scale. The machine handles discovery, naming, and SQL generation. You get clean DDL and reproducible table creation.
The Machine's Mantra: "In discovery we trust, in automation we serve, and in the machine we find the path to scalable data access."
This tutorial provides the complete machinery for automating parquet_s3_fdw table creation. The machine handles the complexity, you get clean SQL.