23. Data Ingestion — SQL Server, BigQuery, Firestore
Quote
“Data is a precious thing and will last longer than the systems themselves.”
— Tim Berners-Lee, attributed remark (c. 2006)
Summary
Setup
Environment variables (GCP_SQL_IP, GCP_SQL_PASSWORD, GCP_SA_KEY_PATH), service account credentials, and shared GCP client initialization (bigquery.Client, storage.Client, firestore.Client).
Two SQL Server connection factories: sql_pymssql (lightweight scripting) and sql_pyodbc (ODBC Driver 18, production-grade, supports fast_executemany).
Benchmark infrastructure: tier definitions (2.5K / 75K / 750K rows), formatting helpers, and a shared result recorder that persists results to JSON.
Schema Setup
DDL for dbo.ohlcv_bench staging table in SQL Server (CREATE TABLE with explicit column types).
BigQuery dataset and table creation via bq_client.create_dataset and bq_client.create_table with an explicit schema.
Local → SQL Server Ingestion
Three paths benchmarked: pyodbc with fast_executemany, bcp CLI via subprocess, and SQLAlchemy with to_sql.
fast_executemany is the fastest pure-Python path; bcp outperforms it at 750K rows.
Local → BigQuery Ingestion
Three paths benchmarked: load_table_from_file (Python client), bq load CLI, and pandas_gbq.to_gbq.
load_table_from_file with Parquet is the fastest and most schema-safe path.
Local → Firestore Ingestion
Two paths benchmarked: batch.set (500-doc commit loop) and BulkWriter (async flush with auto-retry).
BulkWriter is faster at scale and handles 429 back-pressure automatically.
GCS → BigQuery Ingestion
load_table_from_uri for CSV, JSON, and Parquet source files already staged in gs://{BUCKET_NAME}/bronze/.
Parquet path is fastest; WriteDisposition.WRITE_TRUNCATE used for reproducible re-runs.
GCS → SQL Server Ingestion
GCS blob downloaded via download_as_bytes, parsed in-memory, and inserted with fast_executemany.
No local disk write; suitable for containerized pipelines without persistent storage.
Cross-Service Transfers
BQ → SQL Server: to_dataframe() + fast_executemany with chunked iteration.
SQL Server → BQ: read_sql into a DataFrame, optional GCS staging, then load_table_from_dataframe.
SQL Server → Firestore: read_sql rows converted to dicts and ingested via BulkWriter.
Export
Local CSV export via csv.writer from a SQL Server SELECT cursor.
GCS export via BigQuery extract_table method targeting a GCS URI; supports CSV, JSON, and Avro.
Summary
Benchmark results table aggregated from the persisted JSON store; displayed as a DataFrame.
Plotly bar chart comparing throughput (rows/s) across all methods and tiers.
Glossary
bulk insert
Loading many rows in a single database operation rather than row-by-row. The core performance technique for all three targets in this note.
Contrast with an INSERT loop, which issues one network round-trip per row and runs approximately 100× slower at 75K+ rows.
When to use bulk insert
Use any bulk API (fast_executemany, bcp, load_table_from_file, BulkWriter) whenever inserting more than a few hundred rows. The overhead of batch setup is amortized after roughly 500 rows.
fast_executemany
A pyodbc connection option that sends an entire array of parameter tuples to the ODBC driver in one call, eliminating per-row round-trips to SQL Server.
Set cursor.fast_executemany = True before calling cursor.executemany(); the default is False, which falls back to single-row inserts.
Default mode is single-row
Omitting fast_executemany = True silently degrades to one insert per row. At 750K rows this is the difference between ~10 s and ~200 s.
BCP (Bulk Copy Program)
A SQL Server command-line utility that streams CSV or native-format data directly into SQL Server page structures, bypassing the SQL parser and row-at-a-time logging.
Requires the bcp binary on PATH; authentication flags differ between Windows Auth (-T) and SQL Auth (-U/-P). Fastest path for large local file loads.
BCP vs fast_executemany
BCP outperforms fast_executemany at 750K rows because it writes directly to data pages. Use fast_executemany for in-process pipelines where spawning a subprocess is undesirable.
BigQuery Load Job
An asynchronous GCP job (bigquery.LoadJobConfig) that reads a file from local disk or a GCS URI and writes rows into a BigQuery table. Preferred over the Streaming Insert API for batch workloads.
Load jobs are free per row; the Streaming Insert API charges per byte. Load jobs are subject to a 1,500-jobs-per-table-per-day quota.
Load job vs Streaming Insert quota
Do not substitute streaming inserts for load jobs on large batch ETL — per-byte costs accumulate quickly. Reserve streaming inserts for low-latency, low-volume append scenarios.
GCS staging
Uploading a file to Google Cloud Storage before triggering a BigQuery load job via load_table_from_uri. Required when the file exceeds the 10 GB direct-upload cap on load_table_from_file.
The recommended pattern for files above a few hundred MB: write to gs://{bucket}/bronze/ then call load_table_from_uri with the GCS URI.
Always stage Parquet for large loads
GCS staging with Parquet eliminates client-side data transfer entirely. The BigQuery service reads directly from GCS, and column pruning keeps I/O minimal even for wide tables.
Parquet
A columnar binary file format with built-in compression (Snappy by default) and embedded schema metadata. BigQuery can read Parquet natively without schema inference.
Faster than CSV or JSON for BigQuery ingestion because the columnar layout allows the service to skip columns not in the target schema, reducing bytes read.
Parquet schema enforcement
When loading Parquet to BigQuery, set autodetect=False and supply an explicit schema in LoadJobConfig to catch type mismatches at load time rather than at query time.
BulkWriter
A Firestore client abstraction (firestore.Client.bulk_writer()) that queues write operations internally and flushes in batches of up to 500 documents. Supports set, update, delete, and create.
Automatically retries on 429 RESOURCE_EXHAUSTED errors with exponential back-off, making it resilient to Firestore rate limits without manual retry logic.
document.set() in a loop is not bulk
Calling doc_ref.set(data) in a Python loop issues one gRPC call per document. At 75K documents this can take minutes and will hit rate limits. Always use BulkWriter for batch writes.
pandas_gbq
A Python library wrapping the BigQuery Storage Write API; exposes a single pandas_gbq.to_gbq(df, table_id) call that serializes a DataFrame and uploads it to BigQuery.
Convenient for mid-size DataFrames (up to ~250K rows); above that, load_table_from_file with Parquet is faster. Set TQDM_DISABLE=1 in the environment to suppress progress-bar output in notebooks.
Suppress tqdm in production notebooks
pandas_gbq uses tqdm internally. Set os.environ['TQDM_DISABLE'] = '1' before any import to prevent progress bars from polluting notebook output and CI logs.
load_table_from_uri
A bigquery.Client method that submits a load job reading directly from one or more GCS URIs (gs://bucket/path/*.parquet). No data passes through the client machine.
The fastest BigQuery ingestion path for large files. Always set WriteDisposition explicitly in LoadJobConfig; the default (WRITE_APPEND) silently duplicates rows on re-runs.
Default WriteDisposition appends
Omitting write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE on a repeated load job doubles the row count each run. Always set disposition explicitly for reproducible pipelines.
benchmark tier
A row-count category used in this note to measure ingestion throughput across methods: 2.5K (small / in-memory baseline), 75K (medium / realistic daily batch), 750K (large / full production load).
Results at the 2.5K tier are dominated by connection overhead and rarely predict behavior at 750K. Always benchmark at the tier closest to production volume.
Tier rationale
The three tiers correspond to the three benchmark CSV/Parquet files in DATA_DIR. Each tier file contains OHLCV rows for a synthetic equity universe generated at that scale.
WriteDisposition
A BigQuery LoadJobConfig enum controlling what happens to existing table data before the load: WRITE_TRUNCATE (delete all rows first), WRITE_APPEND (add rows to existing data), or WRITE_EMPTY (fail if the table is non-empty).
Must be set explicitly for reproducible benchmarks. The default is WRITE_APPEND, which accumulates duplicate rows across re-runs.
WRITE_APPEND is the default
Forgetting to set write_disposition causes silent row duplication. Use WRITE_TRUNCATE for all benchmark and ETL loads; reserve WRITE_APPEND only for intentional incremental appends.
cross-service transfer
Moving data between two cloud services (e.g., BigQuery → SQL Server, SQL Server → Firestore) without writing to local disk. Reduces egress costs and eliminates intermediate file management.
The typical pattern: read into a Python iterator or DataFrame in memory, then write to the target using that service’s bulk API. For very large result sets, chunk the read to avoid OOM.
In-memory buffering at scale
Calling to_dataframe() on a 750K-row BigQuery result loads the entire result set into RAM. Use client.list_rows(..., page_size=CHUNK_SIZE) with a chunked insert loop for large transfers.
extract_table
A bigquery.Client method that exports a BigQuery table to one or more GCS objects as CSV, newline-delimited JSON, or Avro. The export runs server-side; no data passes through the client.
For local export, data must first flow through to_dataframe() and then be written with csv.writer or df.to_csv() — significantly slower for large tables than a GCS extract.
Wildcard URIs for large exports
When exporting tables larger than 1 GB, use a wildcard URI (gs://bucket/export/data-*.csv) so BigQuery can shard the output across multiple files in parallel.
Python example implementing the workflow described above.
# Suppress tqdm progress bars globally (pandas_gbq uses tqdm internally)import osos.environ['TQDM_DISABLE'] = '1'import warningsfrom sqlalchemy import exc# Suppress SQLAlchemy unrecognized version warnings to keep console output cleanwarnings.filterwarnings('ignore', category=exc.SAWarning)# All imports for data ingestion benchmarking across GCP services# Standard libraryimport jsonimport osimport shutilimport subprocessimport tempfileimport timefrom datetime import datetime, timezonefrom io import BytesIO, StringIOfrom pathlib import Pathfrom urllib.parse import quote_plus# Data & serialisationimport pandas as pdimport pandas_gbqimport pyarrow as paimport pyarrow.parquet as pq# SQL Serverimport pymssqlimport pyodbcfrom sqlalchemy import create_engine# Google Cloudfrom dotenv import load_dotenvfrom google.cloud import bigquery, firestore, storagefrom google.cloud import bigquery_connection_v1# Visualisationimport plotly.graph_objects as goimport plotly.express as pxfrom IPython.display import display# Render DataFrames as HTMLhtml_formatter = get_ipython().display_formatter.formatters['text/html'] # type: ignorehtml_formatter.for_type(pd.DataFrame, lambda df: df.to_html())_ = html_formatter.for_type(pd.Series, lambda s: s.to_frame().to_html())
Python example implementing the workflow described above.
# Load .env and define project constantsload_dotenv(override=True)# ── Project constants ──PROJECT_ID = 'seclab-dev-ap-26'REGION = 'europe-west1'BUCKET_NAME = f'{PROJECT_ID}-data'BQ_DATASET = 'index_data'FIRESTORE_DB = 'seclab-scores'SQL_IP = os.environ['GCP_SQL_IP']SQL_PASSWORD = os.environ['GCP_SQL_PASSWORD']SA_KEY_PATH = os.environ.get('GCP_SA_KEY_PATH', './gcp-sa-key.json')DATA_DIR = Path(r'C:\Users\aperi\DEV\LANG\data')CHUNK_SIZE = 10_000os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = SA_KEY_PATH# ── Benchmark table/collection names ──SQL_BENCH_TABLE = 'dbo.ohlcv_bench'BQ_BENCH_TABLE = f'{PROJECT_ID}.{BQ_DATASET}.ohlcv_bench'FS_COLLECTION = 'ohlcv_bench'# ── GCP clients (authenticated via service account key) ──bq_client = bigquery.Client(project=PROJECT_ID)gcs_client = storage.Client(project=PROJECT_ID)bucket = gcs_client.bucket(BUCKET_NAME)fs_client = firestore.Client(project=PROJECT_ID, database=FIRESTORE_DB)# ── SQL Server connections ──## pymssql: lightweight FreeTDS-based driver, good for quick queries and scripting.# pyodbc: ODBC Driver 18 with TLS encryption — production-grade, supports fast_executemany.def sql_pymssql(): return pymssql.connect(server=SQL_IP, user='sqlserver', password=SQL_PASSWORD, port='1433', database='stoxx', login_timeout=15)ODBC_CONN = ( f'DRIVER={{ODBC Driver 18 for SQL Server}};' f'SERVER={SQL_IP},1433;DATABASE=stoxx;' f'UID=sqlserver;PWD={SQL_PASSWORD};' f'Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=15;')def sql_pyodbc(): return pyodbc.connect(ODBC_CONN)# SQLAlchemy engines (for pandas.to_sql / pandas.read_sql)sql_engine_odbc = create_engine(f'mssql+pyodbc:///?odbc_connect={quote_plus(ODBC_CONN)}')sql_engine_pymssql = create_engine( f'mssql+pymssql://sqlserver:{quote_plus(SQL_PASSWORD)}@{SQL_IP}/stoxx')# ── CLI tool paths ──BCP = shutil.which('bcp') or 'bcp'BQ_CLI = shutil.which('bq.cmd') or shutil.which('bq') or 'bq'GCLOUD = shutil.which('gcloud.cmd') or shutil.which('gcloud') or 'gcloud'# ── Helpers ──def _sql_truncate(table): """Truncate a SQL Server table before benchmark run.""" with sql_pymssql() as conn: conn.cursor().execute(f'TRUNCATE TABLE {table}') conn.commit()def _fs_delete_collection(collection, batch_size=500): """Delete all documents in a Firestore collection in batches to avoid query timeout.""" deleted = 0 while True: docs = list(fs_client.collection(collection).limit(batch_size).select([]).stream()) if not docs: break batch = fs_client.batch() for doc in docs: batch.delete(doc.reference) batch.commit() deleted += len(docs) return deleted# ── Test all connections ──with sql_pymssql() as conn: cursor = conn.cursor() cursor.execute('SELECT @@VERSION') print(f' SQL Server: {str(cursor.fetchone()[0])[:60]}...')print(f' BigQuery: {BQ_DATASET} ({PROJECT_ID})')print(f' Firestore: {FIRESTORE_DB} ({PROJECT_ID})')print(f' GCS: gs://{BUCKET_NAME}')
SQL Server: Microsoft SQL Server 2022 (RTM-CU23) (KB5078297) - 16.0.4236...
index_data (seclab-dev-ap-26)
seclab-scores (seclab-dev-ap-26)
gs://seclab-dev-ap-26-data
Python Data Ingestion - SQL Server, BigQuery, Firestore Setup
Helper functions and benchmark infrastructure shared across all ingestion sections. Run these cells once before executing any benchmark.
Helpers and test data
Formatting utilities, tier definitions, and the benchmark recorder used by every ingestion section.
Formatting helpers
Human-readable formatters for row counts, elapsed time, byte sizes, and throughput rates. Used in every benchmark output row and summary table.
Python example implementing the workflow described above.
def fmt_rows(n): if n < 1000: return str(n) if n < 1_000_000: return f'{n/1000:.1f}K' return f'{n/1_000_000:.1f}M'def fmt_time(ms): if ms < 1000: return f'{ms:.0f}ms' if ms < 60_000: return f'{ms/1000:.1f}s' m, s = divmod(ms / 1000, 60) if s == 0: return f'{int(m)}min' return f'{int(m)}m{s:.0f}s'def fmt_size(b): if b < 1024: return f'{b} B' if b < 1024 * 1024: return f'{b/1024:.0f} KB' if b < 1024 ** 3: return f'{b/(1024*1024):.2f} MB' return f'{b/(1024**3):.2f} GB'def fmt_rate(rows, ms): if ms <= 0: return '-' rate = rows / (ms / 1000) if rate < 1000: return f'{rate:.0f} rows/s' if rate < 1_000_000: return f'{rate/1000:.1f}K rows/s' return f'{rate/1_000_000:.1f}M rows/s'
Define file tiers for ingestion benchmarks
Three file tiers (small 2.5K, medium 75K, large 750K rows) with paths for CSV, JSON, and Parquet formats. The unified OHLCV schema — generated from combined eurostoxx50, stoxxusa50, and oil20 data — is consistent across all tiers and all target systems. GCS staging paths mirror the local tier names under the bronze/ prefix.
Python example implementing the workflow described above.
OHLCV_COLS = ['id', 'symbol', 'date', 'open', 'high', 'low', 'close', 'adj_close', 'volume', 'dividends', 'stock_splits', 'is_filled']tiers = { 'small': {'csv': DATA_DIR / 'ingest_small.csv', 'json': DATA_DIR / 'ingest_small.json', 'parquet': DATA_DIR / 'ingest_small.parquet'}, 'medium': {'csv': DATA_DIR / 'ingest_medium.csv', 'json': DATA_DIR / 'ingest_medium.json', 'parquet': DATA_DIR / 'ingest_medium.parquet'}, 'large': {'csv': DATA_DIR / 'ingest_large.csv', 'json': DATA_DIR / 'ingest_large.json', 'parquet': DATA_DIR / 'ingest_large.parquet'},}# Count rows dynamically (header excluded for CSV)for tier, info in tiers.items(): with open(info['csv']) as f: info['rows'] = sum(1 for _ in f) - 1gcs_paths = {tier: {fmt: f'bronze/{fmt}/ingest_{tier}.{fmt}' for fmt in ['csv', 'json', 'parquet']} for tier in tiers}tier_summary = pd.DataFrame([ { 'tier': tier, 'rows': f'{info["rows"]:,}', 'CSV': fmt_size(info['csv'].stat().st_size), 'JSON': fmt_size(info['json'].stat().st_size), 'Parquet': fmt_size(info['parquet'].stat().st_size), } for tier, info in tiers.items()]).set_index('tier')display(tier_summary)
rows
CSV
JSON
Parquet
tier
small
2,500
190 KB
468 KB
130 KB
medium
75,000
5.66 MB
13.81 MB
2.96 MB
large
750,000
57.31 MB
138.85 MB
18.89 MB
Ingestion benchmark helper
Times a single ingestion function and upserts the result into an in-memory list and a persistent JSON file, keyed by (method, tier). Re-running a benchmark for the same key overwrites the previous record, keeping the results file stable across partial re-runs.
Python example implementing the workflow described above.
INGEST_RESULTS_FILE = DATA_DIR / 'ingestion_results.json'def _load_results() -> list: if INGEST_RESULTS_FILE.exists(): return json.loads(INGEST_RESULTS_FILE.read_text()) return []def _save_results(results: list) -> None: INGEST_RESULTS_FILE.write_text(json.dumps(results, indent=2))ingest_results = _load_results()def bench_ingest(method_name, ingest_fn, tier_name): t0 = time.perf_counter() row_count = ingest_fn() elapsed_ms = (time.perf_counter() - t0) * 1000 record = { 'method': method_name, 'tier': tier_name, 'rows': row_count, 'rows_fmt': fmt_rows(row_count), 'elapsed_ms': round(elapsed_ms, 1), 'elapsed': fmt_time(elapsed_ms), 'rate': fmt_rate(row_count, elapsed_ms), 'rate_raw': round(row_count / (elapsed_ms / 1000), 1) if elapsed_ms > 0 else 0, } # Upsert in persistent store all_r = _load_results() all_r = [r for r in all_r if (r['method'], r['tier']) != (method_name, tier_name)] all_r.append(record) _save_results(all_r) # Upsert in memory global ingest_results ingest_results = [r for r in ingest_results if (r['method'], r['tier']) != (method_name, tier_name)] ingest_results.append(record) return recordprint(f' Loaded {len(ingest_results)} existing results from {INGEST_RESULTS_FILE.name}')
Loaded 67 existing results from ingestion_results.json
Schema Setup
Create unified ohlcv_bench staging table in SQL Server and BigQuery.
Same OHLCV schema everywhere. Firestore is schemaless — no setup needed. The SQL Server DDL below follows the same bronze-layer-loading patterns used in the medallion architecture, while the BigQuery schema aligns with the format decisions documented in data-loading-and-export.
Staging tables
DDL cells that create the ohlcv_bench table in SQL Server and BigQuery before benchmarks run.
pymssql — create staging table in SQL Server
Creates dbo.ohlcv_bench if it does not already exist. All columns use NVARCHAR(50) to match the bronze-layer loading pattern used in the medallion architecture — type coercion is deferred to the transformation layer.
Python example implementing the workflow described above.
with sql_pymssql() as conn: cursor = conn.cursor() cursor.execute(''' IF OBJECT_ID('dbo.ohlcv_bench', 'U') IS NULL CREATE TABLE dbo.ohlcv_bench ( id nvarchar(50), symbol nvarchar(50), date nvarchar(50), [open] nvarchar(50), high nvarchar(50), low nvarchar(50), [close] nvarchar(50), adj_close nvarchar(50), volume nvarchar(50), dividends nvarchar(50), stock_splits nvarchar(50), is_filled nvarchar(50) ) ''') conn.commit()
google-cloud-bigquery — create staging table in BigQuery
Creates ohlcv_bench in the index_data dataset with a fully typed schema. create_table(..., exists_ok=True) is idempotent — safe to re-run between benchmark sessions.
Python example implementing the workflow described above.
Benchmarks local-file-to-SQL-Server ingestion using three strategies: row-by-row executemany (baseline, small tier only), fast_executemany via pyodbc (medium and large tiers), and the bcp CLI utility. Covers CSV, JSON, and Parquet source formats.
Ingestion methods
Each method covers a different driver or protocol path; run all three tiers per method to capture the performance curve.
Ingest CSV into SQL Server from local using pymssql executemany over TDS
Parameterised INSERT, one row per network round-trip. Simple but slow — included as baseline for the small tier only.
pymssql.executemany() — simplest ingestion, one round-trip per row. Parameterised queries prevent injection. Works with any SQL Server. Extremely slow for >5K rows — use fast_executemany or bcp instead.
Python example implementing the workflow described above.
def pymssql_executemany(): _sql_truncate('ohlcv_bench') df = pd.read_csv(tiers['small']['csv'], dtype=str, keep_default_na=False) cols = ', '.join(f'[{c}]' for c in df.columns) placeholders = ', '.join(['%s'] * len(df.columns)) sql = f'INSERT INTO dbo.ohlcv_bench ({cols}) VALUES ({placeholders})' rows = [tuple(r) for r in df.values] with sql_pymssql() as conn: cursor = conn.cursor() cursor.executemany(sql, rows) conn.commit() return len(rows)r = bench_ingest('pymssql_executemany', pymssql_executemany, 'small')print(f' small {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
small 2.5K 59.5s 42 rows/s
Ingest CSV into SQL Server from local using pyodbc fast_executemany over ODBC Driver 18 (TLS)
Packs all rows into a single TDS packet. ODBC Driver 18 with TLS encryption. 5-10x faster than plain executemany.
fast_executemany=True packs all rows into a single TDS packet per chunk — 5–10x faster. ODBC Driver 18 with TLS. Chunk at 10K rows to avoid timeouts. For >1M rows, bcp is 2–5x faster.
Always chunk large datasets
Always chunk large datasets — sending all rows in one executemany() causes Communication link failure. Always set fast_executemany=True — without it, falls back to row-by-row.
Safe Pattern: Chunked executemany
Split the DataFrame into chunks of 10 000 rows and call cursor.executemany() per
chunk with fast_executemany=True set on the pyodbc connection. Wrap each chunk
in a try/except to log the failing range without rolling back the entire load —
then re-run only the failed chunks.
Python example implementing the workflow described above.
def pyodbc_fast(tier): _sql_truncate('ohlcv_bench') df = pd.read_csv(tiers[tier]['csv'], dtype=str, keep_default_na=False) cols = ', '.join(f'[{c}]' for c in df.columns) placeholders = ', '.join(['?'] * len(df.columns)) sql = f'INSERT INTO dbo.ohlcv_bench ({cols}) VALUES ({placeholders})' rows = [tuple(r) for r in df.values] with sql_pyodbc() as conn: cursor = conn.cursor() cursor.fast_executemany = True for start in range(0, len(rows), CHUNK_SIZE): cursor.executemany(sql, rows[start:start + CHUNK_SIZE]) conn.commit() return len(rows)print(f' {"tier":<8s} {"rows":>8s} {"time":>10s} {"rate":>14s}')for tier in tiers: r = bench_ingest('pyodbc_fast_executemany', lambda t=tier: pyodbc_fast(t), tier) print(f' {tier:<8s} {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
tier rows time rate
small 2.5K 1.5s 1.7K rows/s
medium 75.0K 5.7s 13.0K rows/s
large 750.0K 54.4s 13.8K rows/s
Ingest CSV into SQL Server from local using bcp (Bulk Copy Program) over TDS
The bcp CLI is the fastest bulk loader for SQL Server. Native TDS bulk-insert protocol — bypasses the SQL parser entirely. Production standard for ETL pipelines.
bcp streams rows via native TDS bulk-insert protocol — bypasses SQL parser, writes directly to table pages. 10–50x faster than row-by-row. Always use -F 2 (skip header), -b 10000 (batch size for recovery).
Don't hardcode passwords in CLI args
Don’t hardcode passwords in CLI args — use -T (trusted) or env vars. Don’t skip -b (batch size) — one failed row rolls back the entire load.
Safe Pattern: Trusted Auth with Batch Recovery
Use -T (Windows Integrated Authentication) or pass credentials via environment
variables read at runtime — never interpolated into the shell command string.
Always supply -b 10000 so that a bad row aborts only that batch, not the whole
load; failed batches are logged and can be re-run in isolation.
Python example implementing the workflow described above.
BCP = shutil.which('bcp') or 'bcp'def bcp_import(tier): _sql_truncate('ohlcv_bench') result = subprocess.run([ BCP, 'dbo.ohlcv_bench', 'in', str(tiers[tier]['csv']), '-S', f'{SQL_IP},1433', '-U', 'sqlserver', '-P', SQL_PASSWORD, '-d', 'stoxx', '-c', '-t', ',', '-F', '2', '-b', '10000', '-u', # -u = trust server cert (Cloud SQL self-signed) ], capture_output=True, text=True) if result.returncode != 0: print(f' bcp error: {result.stdout[:200]} {result.stderr[:200]}') return 0 for line in result.stdout.splitlines(): if 'rows copied' in line.lower(): return int(line.split()[0]) return tiers[tier]['rows']print(f' {"tier":<8s} {"rows":>8s} {"time":>10s} {"rate":>14s}')for tier in tiers: r = bench_ingest('bcp_import', lambda t=tier: bcp_import(t), tier) print(f' {tier:<8s} {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
tier rows time rate
small 2.5K 770ms 3.2K rows/s
medium 75.0K 2.7s 28.1K rows/s
large 750.0K 21.0s 35.7K rows/s
Ingest JSON into SQL Server from local using pyodbc fast_executemany over ODBC Driver 18 (TLS)
Reads newline-delimited JSON with pandas, then inserts via fast_executemany. Same TLS-encrypted ODBC path as CSV.
Reads newline-delimited JSON with pandas, converts to strings, inserts via fast_executemany. Handles nested/optional fields. JSON nulls become NaN — must fillna before INSERT.
Python example implementing the workflow described above.
def json_to_sql(tier): _sql_truncate('ohlcv_bench') df = pd.read_json(tiers[tier]['json'], lines=True, dtype=str) df = df.fillna('') cols = ', '.join(f'[{c}]' for c in df.columns) placeholders = ', '.join(['?'] * len(df.columns)) sql = f'INSERT INTO dbo.ohlcv_bench ({cols}) VALUES ({placeholders})' rows = [tuple(r) for r in df.values] with sql_pyodbc() as conn: cursor = conn.cursor() cursor.fast_executemany = True for start in range(0, len(rows), CHUNK_SIZE): cursor.executemany(sql, rows[start:start + CHUNK_SIZE]) conn.commit() return len(rows)print(f' {"tier":<8s} {"rows":>8s} {"time":>10s} {"rate":>14s}')for tier in tiers: r = bench_ingest('json_to_sql_fast', lambda t=tier: json_to_sql(t), tier) print(f' {tier:<8s} {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
tier rows time rate
small 2.5K 722ms 3.5K rows/s
medium 75.0K 6.2s 12.2K rows/s
large 750.0K 59.0s 12.7K rows/s
Ingest Parquet into SQL Server from local using pyodbc fast_executemany over ODBC Driver 18 (TLS)
Reads Parquet with pyarrow (fastest local parse), then inserts via fast_executemany. Parquet’s columnar format makes the read near-instant.
Reads Parquet with pyarrow (zero-copy columnar, near-instant), then inserts via fast_executemany. Parquet’s schema is embedded — no column mapping errors. Smallest file size = less disk I/O.
Python example implementing the workflow described above.
def parquet_to_sql(tier): _sql_truncate('ohlcv_bench') df = pd.read_parquet(tiers[tier]['parquet']).astype(str) df = df.replace('nan', '').replace('None', '') cols = ', '.join(f'[{c}]' for c in df.columns) placeholders = ', '.join(['?'] * len(df.columns)) sql = f'INSERT INTO dbo.ohlcv_bench ({cols}) VALUES ({placeholders})' rows = [tuple(r) for r in df.values] with sql_pyodbc() as conn: cursor = conn.cursor() cursor.fast_executemany = True for start in range(0, len(rows), CHUNK_SIZE): cursor.executemany(sql, rows[start:start + CHUNK_SIZE]) conn.commit() return len(rows)print(f' {"tier":<8s} {"rows":>8s} {"time":>10s} {"rate":>14s}')for tier in tiers: r = bench_ingest('parquet_to_sql_fast', lambda t=tier: parquet_to_sql(t), tier) print(f' {tier:<8s} {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
tier rows time rate
small 2.5K 837ms 3.0K rows/s
medium 75.0K 5.8s 12.9K rows/s
large 750.0K 55.2s 13.6K rows/s
Ingest CSV into SQL Server using BULK INSERT (T-SQL) and SSMS Import Wizard (reference)
BULK INSERT: Native T-SQL command. Requires the file to be accessible from the server filesystem — not supported on Cloud SQL (server can’t read client-side files). Use bcp instead.
SSMS / Azure Data Studio Import Wizard: Graphical import via right-click → Tasks → Import Flat File. Best for ad-hoc one-off imports under 100K rows. Not benchmarkable from a notebook.
Local → BigQuery Ingestion
Load data from local files into BigQuery. Three formats (CSV, JSON, Parquet),
plus bq CLI and Storage Write API.
Ingestion methods
Covers the Python client library, bq CLI, pandas-gbq Storage Write API, and read-only external tables.
Ingest CSV into BigQuery from local using google-cloud-bigquery load_table_from_file over HTTPS
Server parses CSV rows. skip_leading_rows=1 for header. WRITE_TRUNCATE clears before load.
Uploads CSV to BigQuery’s load job API over HTTPS. Server-side parsing, atomic (fully succeeds or fails). Always set skip_leading_rows=1. Use explicit schema in production (not autodetect). For large files, use Parquet (5x compression) or GCS staging.
autodetect=True is unsafe in production
BigQuery infers types from the first few rows — a column with nulls early in the file may be typed as STRING instead of FLOAT, causing silent data loss downstream.
Provide an explicit schema in production
Pass schema=bq_schema to LoadJobConfig and set autodetect=False. The schema defined in the Setup section already captures correct types for all OHLCV columns.
Python example implementing the workflow described above.
tier rows time rate
small 2.5K 3.1s 815 rows/s
medium 75.0K 6.8s 11.1K rows/s
large 750.0K 22.7s 33.1K rows/s
Ingest JSON into BigQuery from local using google-cloud-bigquery load_table_from_file over HTTPS
Server parses newline-delimited JSON. Auto-detects schema from keys.
NDJSON (one JSON object per line) — handles nested/repeated fields natively. Schema auto-detected from keys. Use for API dumps or nested data. For flat tabular data, CSV/Parquet is 3–5x smaller.
Python example implementing the workflow described above.
tier rows time rate
small 2.5K 4.5s 554 rows/s
medium 75.0K 7.3s 10.3K rows/s
large 750.0K 27.7s 27.1K rows/s
Ingest Parquet into BigQuery from local using google-cloud-bigquery load_table_from_file over HTTPS
Fastest format — columnar, compressed, schema embedded. No parsing overhead.
Parquet schema maps directly to BQ schema — no parsing, no autodetect needed. Snappy compression = smallest upload. Type-safe end-to-end. BigQuery-recommended format for production pipelines.
Python example implementing the workflow described above.
tier rows time rate
small 2.5K 2.8s 884 rows/s
medium 75.0K 3.6s 20.8K rows/s
large 750.0K 8.3s 90.2K rows/s
Ingest CSV into BigQuery from local using bq CLI bq load over HTTPS
Command-line tool — same load job API but no Python code needed.
bq load — same load job API as Python but no code needed. Just the gcloud SDK. Good for shell scripts, CI/CD, and one-off loads.
Python example implementing the workflow described above.
BQ_CMD = shutil.which('bq.cmd') or shutil.which('bq') or 'bq'def bq_cli_csv(tier): table_id = f'{BQ_DATASET}.ohlcv_bench' result = subprocess.run([ BQ_CMD, 'load', '--source_format=CSV', '--skip_leading_rows=1', '--autodetect', '--replace', f'--project_id={PROJECT_ID}', table_id, str(tiers[tier]['csv']), ], capture_output=True, text=True) if result.returncode != 0: print(f' bq error: {result.stderr[:200]}') return 0 return tiers[tier]['rows']print(f' {"tier":<8s} {"rows":>8s} {"time":>10s} {"rate":>14s}')for tier in tiers: r = bench_ingest('bq_cli_csv', lambda t=tier: bq_cli_csv(t), tier) print(f' {tier:<8s} {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
tier rows time rate
small 2.5K 9.0s 278 rows/s
medium 75.0K 7.9s 9.5K rows/s
large 750.0K 19.7s 38.0K rows/s
Ingest data into BigQuery using pandas-gbq Storage Write API over gRPC
Highest throughput for streaming ingestion. pandas_gbq.to_gbq() uses the Storage Write API when available.
pandas_gbq.to_gbq() uses the Storage Write API (gRPC) — writes directly to BQ storage with row-level acknowledgment and exactly-once semantics. Highest throughput for streaming. For batch loads, load jobs are simpler.
Python example implementing the workflow described above.
tier rows time rate
small 2.5K 4.7s 527 rows/s
medium 75.0K 4.0s 18.8K rows/s
large 750.0K 11.8s 63.6K rows/s
Query data from GCS without loading using BigQuery External Tables over internal network
Query CSV/JSON/Parquet in GCS directly via SQL. Zero ingestion time — slower queries but no storage cost.
External table points to a GCS file — queries read directly at query time. Zero ingestion, zero storage cost. 10–100x slower than native tables. Use for ad-hoc exploration; not for production dashboards.
Use external tables for one-off exploration, not production dashboards
External tables re-scan the GCS file on every query. For recurring workloads or dashboards, load the data into a native BigQuery table first — queries will be 10–100x faster and cheaper.
Python example implementing the workflow described above.
def bq_external_table(tier): ext_table = f'{PROJECT_ID}.{BQ_DATASET}.ohlcv_bench_ext' uri = f'gs://{BUCKET_NAME}/{gcs_paths[tier]["parquet"]}' ext_config = bigquery.ExternalConfig('PARQUET') ext_config.source_uris = [uri] table = bigquery.Table(ext_table) table.external_data_configuration = ext_config table = bq_client.create_table(table, exists_ok=True) result = bq_client.query(f'SELECT COUNT(*) as cnt FROM `{ext_table}`').result() row_count = list(result)[0].cnt bq_client.delete_table(ext_table, not_found_ok=True) return row_countprint(f' {"tier":<8s} {"rows":>8s} {"time":>10s} {"rate":>14s}')for tier in tiers: r = bench_ingest('bq_external_table', lambda t=tier: bq_external_table(t), tier) print(f' {tier:<8s} {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
tier rows time rate
small 2.5K 1.1s 2.2K rows/s
medium 75.0K 1.2s 64.1K rows/s
large 750.0K 9.7s 77.6K rows/s
Ingest data into BigQuery using Google Cloud Console Web UI (reference)
Console → BigQuery → Dataset → Create Table → Upload (up to 10 MB) or Google Cloud Storage.
Not benchmarkable from a notebook.
Local → Firestore Ingestion
Write OHLCV data into Firestore. Each row becomes a document in the ohlcv_bench collection.
Ingestion methods
Compares manual batch.set() (500-doc limit) against BulkWriter (parallel, auto-throttled).
Ingest CSV into Firestore from local using google-cloud-firestore batch.set over gRPC
500-doc batches (Firestore limit). Each batch is a single gRPC call.
500-doc batches (Firestore’s limit). Each batch.commit() is a single atomic gRPC call. Simple API. For >50K docs, BulkWriter is 2–5x faster (parallel batches).
Don't exceed 500 docs per
Don’t exceed 500 docs per batch (rejected). Don’t forget to commit the final partial batch.
Safe Pattern: Flush on Limit
Track a counter alongside the batch; when it reaches 500, call batch.commit()
and immediately open a new fs_client.batch(). After the loop, check whether the
counter is non-zero and commit the final partial batch — this guarantees no rows
are silently dropped even when the total is not an exact multiple of 500.
Python example implementing the workflow described above.
tier rows time rate
small 2.5K 6.7s 372 rows/s
medium 75.0K 3m21s 372 rows/s
large 750.0K 31m13s 400 rows/s
Ingest CSV into Firestore from local using google-cloud-firestore BulkWriter over gRPC
BulkWriter manages batching, retries, and throttling automatically. Parallel writes — the recommended method for bulk ingestion.
BulkWriter manages batching, retries, and rate limiting automatically. 2–5x faster than manual batch.set(). Use for 10K–500K document migrations/backfills. set() overwrites existing documents by ID — no prior delete is needed, which avoids a costly collection scan.
Always call bw.close() before the function returns
BulkWriter buffers writes internally. If the process exits or an exception is raised before close(), buffered documents are silently lost with no error raised.
Wrap in try/finally to guarantee flush
bw = fs_client.bulk_writer()try: for idx, row in df.iterrows(): bw.set(collection.document(str(idx)), row.to_dict())finally: bw.close()
Python example implementing the workflow described above.
tier rows time rate
small 2.5K 4.1s 604 rows/s
medium 75.0K 2m31s 497 rows/s
large 750.0K 25m11s 496 rows/s
Import data into Firestore from GCS using gcloud firestore import and Console UI (reference)
gcloud firestore export/import: Managed backup/restore from GCS. Server-side, fastest for large restores.
Firebase/GCP Console: Manual document creation or import from managed exports.
Both require managed export format (not raw CSV/JSON).
GCS → BigQuery Ingestion
Server-side operation — no data passes through the local machine.
Ingestion methods
Three source formats (CSV, JSON, Parquet) loaded via load_table_from_uri, all flowing within Google’s network.
Ingest CSV into BigQuery from GCS using google-cloud-bigquery load_table_from_uri over internal network
Server-side CSV parse. Data flows GCS → BigQuery within Google’s network.
load_table_from_uri triggers a server-side load — data flows GCS → BQ within Google’s network, no local bandwidth. Supports wildcards (gs://bucket/path/*.csv). Standard data lake pattern.
Python example implementing the workflow described above.
Two-hop: download CSV from GCS into memory (BytesIO), parse with pandas, insert via fast_executemany in 10K-row chunks. No direct GCS→SQL path exists. For GB-scale, use VM-hosted SQL Server or Dataflow instead.
Python example implementing the workflow described above.
def gcs_csv_to_sql(tier): _sql_truncate('ohlcv_bench') blob = bucket.blob(gcs_paths[tier]['csv']) csv_bytes = blob.download_as_bytes() df = pd.read_csv(BytesIO(csv_bytes), dtype=str, keep_default_na=False) cols = ', '.join(f'[{c}]' for c in df.columns) placeholders = ', '.join(['?'] * len(df.columns)) sql = f'INSERT INTO dbo.ohlcv_bench ({cols}) VALUES ({placeholders})' rows = [tuple(r) for r in df.values] with sql_pyodbc() as conn: cursor = conn.cursor() cursor.fast_executemany = True for start in range(0, len(rows), CHUNK_SIZE): cursor.executemany(sql, rows[start:start + CHUNK_SIZE]) conn.commit() return len(rows)print(f' {"tier":<8s} {"rows":>8s} {"time":>10s} {"rate":>14s}')for tier in tiers: r = bench_ingest('gcs_csv_to_sql', lambda t=tier: gcs_csv_to_sql(t), tier) print(f' {tier:<8s} {r["rows_fmt"]:>8s} {r["elapsed"]:>10s} {r["rate"]:>14s}')
tier rows time rate
small 2.5K 2.2s 1.1K rows/s
medium 75.0K 6.5s 11.5K rows/s
large 750.0K 56.2s 13.3K rows/s
Cross-Service Transfers
Move data between SQL Server, BigQuery, and Firestore.
Transfer methods
Five bidirectional paths covering SQL↔BigQuery, SQL→Firestore, BQ→Firestore, and the GCS-staged SQL→BigQuery route.
Transfer data from SQL Server to BigQuery using pymssql query + load_table_from_dataframe over TDS + HTTPS
Query SQL Server → DataFrame → BigQuery. Two-hop via local memory. Self-populates SQL Server first (Step 1: load CSV), then queries and transfers to BigQuery (Step 2).
Python example implementing the workflow described above.
def sql_to_bq(tier): # Step 1: populate SQL Server with this tier's data _sql_truncate('ohlcv_bench') df = pd.read_csv(tiers[tier]['csv'], dtype=str, keep_default_na=False) df.to_sql('ohlcv_bench', con=sql_engine_pymssql, schema='dbo', if_exists='append', index=False) # Step 2: read from SQL Server, load into BigQuery query = f'SELECT TOP {tiers[tier]["rows"]} * FROM dbo.ohlcv_bench' df2 = pd.read_sql(query, con=sql_engine_pymssql) job_config = bigquery.LoadJobConfig( write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, autodetect=True ) job = bq_client.load_table_from_dataframe(df2, BQ_BENCH_TABLE, job_config=job_config) job.result() return job.output_rowsprint(f" {'tier':<8s} {'rows':>8s} {'time':>10s} {'rate':>14s}")for tier in tiers: r = bench_ingest('sql_to_bq', lambda t=tier: sql_to_bq(t), tier) print(f" {tier:<8s} {r['rows_fmt']:>8s} {r['elapsed']:>10s} {r['rate']:>14s}")
tier rows time rate
small 2.5K 7.4s 340 rows/s
medium 75.0K 51.8s 1.4K rows/s
large 750.0K 8m19s 1.5K rows/s
Transfer data from BigQuery to SQL Server using google-cloud-bigquery query + pyodbc fast_executemany over HTTPS + TLS
Query BigQuery → DataFrame → SQL Server. Self-populates BigQuery first (Step 1), then queries and inserts into SQL Server via fast_executemany (Step 2).
Python example implementing the workflow described above.
def bq_to_sql(tier): # Step 1: populate BigQuery with this tier's data df = pd.read_csv(tiers[tier]['csv'], dtype=str, keep_default_na=False) job_config = bigquery.LoadJobConfig( write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, autodetect=True ) job = bq_client.load_table_from_dataframe(df, BQ_BENCH_TABLE, job_config=job_config) job.result() # Step 2: query BigQuery, insert into SQL Server _sql_truncate('ohlcv_bench') results = bq_client.query(f'SELECT * FROM `{PROJECT_ID}.{BQ_DATASET}.ohlcv_bench`') rows = [dict(row) for row in results] df2 = pd.DataFrame(rows).astype(str) df2.to_sql('ohlcv_bench', con=sql_engine_pymssql, schema='dbo', if_exists='append', index=False) return len(rows)print(f" {'tier':<8s} {'rows':>8s} {'time':>10s} {'rate':>14s}")for tier in tiers: r = bench_ingest('bq_to_sql', lambda t=tier: bq_to_sql(t), tier) print(f" {tier:<8s} {r['rows_fmt']:>8s} {r['elapsed']:>10s} {r['rate']:>14s}")
tier rows time rate
small 2.5K 5.5s 451 rows/s
medium 75.0K 57.4s 1.3K rows/s
large 750.0K 8m34s 1.5K rows/s
Transfer data from BigQuery to Firestore using google-cloud-bigquery query + BulkWriter over HTTPS + gRPC
Query BigQuery → iterate results → Firestore BulkWriter. Self-populates BigQuery first (Step 1), then streams rows into Firestore via BulkWriter (Step 2). For real-time serving of scored data.
Python example implementing the workflow described above.
def bq_to_fs(tier): # Step 1: populate BigQuery with this tier's data df = pd.read_csv(tiers[tier]['csv'], dtype=str, keep_default_na=False) job_config = bigquery.LoadJobConfig( write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, autodetect=True ) job = bq_client.load_table_from_dataframe(df, BQ_BENCH_TABLE, job_config=job_config) job.result() # Step 2: query BigQuery, write to Firestore results = bq_client.query(f'SELECT * FROM `{PROJECT_ID}.{BQ_DATASET}.ohlcv_bench`') bw = fs_client.bulk_writer() count = 0 for row in results: doc_ref = fs_client.collection(FS_COLLECTION).document(str(count)) bw.set(doc_ref, {k: str(v) for k, v in dict(row).items()}) count += 1 bw.close() return countprint(f" {'tier':<8s} {'rows':>8s} {'time':>10s} {'rate':>14s}")for tier in tiers: r = bench_ingest('bq_to_fs', lambda t=tier: bq_to_fs(t), tier) print(f" {tier:<8s} {r['rows_fmt']:>8s} {r['elapsed']:>10s} {r['rate']:>14s}")
tier rows time rate
small 2.5K 8.9s 282 rows/s
medium 75.0K 2m41s 466 rows/s
large 750.0K 25m52s 483 rows/s
Transfer data from SQL Server to Firestore using pymssql query + BulkWriter over TDS + gRPC
Direct SQL Server → Firestore bridge. Self-populates SQL Server first (Step 1), then streams rows into Firestore via BulkWriter (Step 2).
Python example implementing the workflow described above.
def sql_to_fs(tier): # Step 1: populate SQL Server with this tier's data _sql_truncate('ohlcv_bench') df = pd.read_csv(tiers[tier]['csv'], dtype=str, keep_default_na=False) df.to_sql('ohlcv_bench', con=sql_engine_pymssql, schema='dbo', if_exists='append', index=False) # Step 2: query SQL Server, write to Firestore with sql_pymssql() as conn: cursor = conn.cursor(as_dict=True) cursor.execute(f'SELECT TOP {tiers[tier]["rows"]} * FROM dbo.ohlcv_bench') bw = fs_client.bulk_writer() count = 0 for row in cursor: doc_ref = fs_client.collection(FS_COLLECTION).document(str(count)) bw.set(doc_ref, {k: str(v) for k, v in row.items()}) count += 1 bw.close() return countprint(f" {'tier':<8s} {'rows':>8s} {'time':>10s} {'rate':>14s}")for tier in tiers: r = bench_ingest('sql_to_firestore', lambda t=tier: sql_to_fs(t), tier) print(f" {tier:<8s} {r['rows_fmt']:>8s} {r['elapsed']:>10s} {r['rate']:>14s}")
tier rows time rate
small 2.5K 6.5s 387 rows/s
medium 75.0K 3m16s 383 rows/s
large 750.0K 32m51s 381 rows/s
Transfer data from SQL Server to BigQuery via GCS staging using pandas + GCS + load_table_from_uri
Production pattern: SQL → Parquet → GCS → BigQuery. Avoids local memory bottleneck for large datasets. For this benchmark the function self-populates SQL Server first (Step 1), then exports to GCS and loads into BigQuery (Step 2).
Prefer GCS staging for large SQL → BigQuery migrations
Loading directly from the client via load_table_from_dataframe requires the full dataset to pass through local memory and network. The GCS staging path (SQL → Parquet → GCS → load_table_from_uri) is server-side from GCS onward and scales to hundreds of GB without memory pressure.
Python example implementing the workflow described above.
def sql_to_bq_gcs(tier): # Step 1: populate SQL Server with this tier's data _sql_truncate('ohlcv_bench') df = pd.read_csv(tiers[tier]['csv'], dtype=str, keep_default_na=False) df.to_sql('ohlcv_bench', con=sql_engine_pymssql, schema='dbo', if_exists='append', index=False) # Step 2: query SQL Server, write CSV to GCS, load into BigQuery query = f'SELECT TOP {tiers[tier]["rows"]} * FROM dbo.ohlcv_bench' df2 = pd.read_sql(query, con=sql_engine_pymssql) gcs_path = f'staging/sql_to_bq_{tier}.csv' blob = bucket.blob(gcs_path) blob.upload_from_string(df2.to_csv(index=False), content_type='text/csv') uri = f'gs://{BUCKET_NAME}/{gcs_path}' job_config = bigquery.LoadJobConfig( source_format=bigquery.SourceFormat.CSV, skip_leading_rows=1, write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, autodetect=True ) job = bq_client.load_table_from_uri(uri, BQ_BENCH_TABLE, job_config=job_config) job.result() blob.delete() return job.output_rowsprint(f" {'tier':<8s} {'rows':>8s} {'time':>10s} {'rate':>14s}")for tier in tiers: r = bench_ingest('sql_to_bq_gcs', lambda t=tier: sql_to_bq_gcs(t), tier) print(f" {tier:<8s} {r['rows_fmt']:>8s} {r['elapsed']:>10s} {r['rate']:>14s}")
tier rows time rate
small 2.5K 6.5s 382 rows/s
medium 75.0K 54.4s 1.4K rows/s
large 750.0K 8m7s 1.5K rows/s
Export
Export data from SQL Server, BigQuery, and Firestore.
Export methods
Three targets: SQL Server to local CSV, BigQuery to GCS, and Firestore collection to NDJSON.
Export SQL Server to CSV using pandas read_sql + to_csv over TDS
Query into DataFrame, write to local CSV.
Python example implementing the workflow described above.
tier rows time rate
small 2.5K 131ms 19.1K rows/s
medium 75.0K 800ms 93.7K rows/s
large 750.0K 6.5s 114.7K rows/s
Export BigQuery to GCS using google-cloud-bigquery extract_table over internal network
Server-side export — BigQuery writes directly to GCS.
Python example implementing the workflow described above.
def bq_export(tier): row_limit = tiers[tier]['rows'] # Create a temp table with the correct row count temp_table = f'{PROJECT_ID}.{BQ_DATASET}.export_temp_{tier}' query = f'CREATE OR REPLACE TABLE `{temp_table}` AS SELECT * FROM `{PROJECT_ID}.{BQ_DATASET}.ohlcv_bench` LIMIT {row_limit}' bq_client.query(query).result() dest_uri = f'gs://{BUCKET_NAME}/exports/ohlcv_{tier}.csv' job_config = bigquery.ExtractJobConfig(destination_format=bigquery.DestinationFormat.CSV) job = bq_client.extract_table(temp_table, dest_uri, job_config=job_config) job.result() row_count = bq_client.get_table(temp_table).num_rows bq_client.delete_table(temp_table, not_found_ok=True) return row_countprint(f" {'tier':<8s} {'rows':>8s} {'time':>10s} {'rate':>14s}")for tier in tiers: r = bench_ingest('bq_export_gcs', lambda t=tier: bq_export(t), tier) print(f" {tier:<8s} {r['rows_fmt']:>8s} {r['elapsed']:>10s} {r['rate']:>14s}")
tier rows time rate
small 2.5K 4.9s 510 rows/s
medium 75.0K 5.2s 14.3K rows/s
large 750.0K 15.8s 47.4K rows/s
Export Firestore to JSON using google-cloud-firestore collection.stream over gRPC
Stream documents, write as NDJSON.
Python example implementing the workflow described above.
def fs_export(tier): path = EXPORT_DIR / f'ohlcv_firestore_{tier}.json' target = tiers[tier]['rows'] count = 0 batch_size = 500 last_doc = None with open(path, 'w') as f: while count < target: query = fs_client.collection(FS_COLLECTION).order_by('__name__').limit(batch_size) if last_doc: query = query.start_after(last_doc) docs = list(query.stream()) if not docs: break for doc in docs: d = doc.to_dict() for k, v in d.items(): if hasattr(v, 'isoformat'): d[k] = v.isoformat() f.write(json.dumps(d) + chr(10)) count += 1 if count >= target: break last_doc = docs[-1] return countprint(f" {'tier':<8s} {'rows':>8s} {'time':>10s} {'rate':>14s}")for tier in tiers: r = bench_ingest('fs_export_json', lambda t=tier: fs_export(t), tier) print(f" {tier:<8s} {r['rows_fmt']:>8s} {r['elapsed']:>10s} {r['rate']:>14s}")
tier rows time rate
small 2.5K 707ms 3.5K rows/s
medium 75.0K 17.0s 4.4K rows/s
large 750.0K 2m45s 4.5K rows/s
Summary
Reloads all persisted benchmark results from JSON and renders comparative bar charts for each target system and transfer type. Run this section at any time to visualise results from previous sessions without re-running ingestion.
Results
Benchmark data and Plotly charts grouped by target system (BigQuery, SQL Server, Firestore, cross-service, export).
Results table
Loads raw results from the persistent JSON file, deduplicates to the latest run per (method, tier) combination, and creates a numeric sort key so large-tier rows appear first in charts.
Python example implementing the workflow described above.
This comparison isolates BigQuery ingestion paths so load_table_from_file, load_table_from_uri, and pandas_gbq.to_gbq() can be evaluated on the same throughput axis before you interpret the chart.
Python example implementing the workflow described above.
Python example implementing the workflow described above.
fig_bq = go.Figure()# Iterate through tiers in order to maintain consistent legend groupingfor tier in ['large', 'medium', 'small']: df_t = df_bq[df_bq['tier'] == tier] if not df_t.empty: fig_bq.add_trace(go.Bar( x=df_t['method'], y=df_t['rate_raw'], name=tier.capitalize() ))fig_bq.update_layout( barmode='group', title='BigQuery Ingestion Performance (Log Scale)', xaxis_title='Ingestion Method', yaxis_title='Speed (Rows / Second)', yaxis_type='log', template='plotly_dark', xaxis_tickangle=-45)fig_bq.show()
SQL Server ingestion benchmark (local and GCS)
This view keeps the SQL Server load paths together so bcp, fast_executemany, and file-format-specific variants can be compared without cross-service noise.
Python example implementing the workflow described above.
Python example implementing the workflow described above.
fig_sql = go.Figure()for tier in ['large', 'medium', 'small']: df_t = df_sql[df_sql['tier'] == tier] if not df_t.empty: fig_sql.add_trace(go.Bar( x=df_t['method'], y=df_t['rate_raw'], name=tier.capitalize() ))fig_sql.update_layout( barmode='group', title='SQL Server Ingestion Performance (Log Scale)', xaxis_title='Ingestion Method', yaxis_title='Speed (Rows / Second)', yaxis_type='log', template='plotly_dark', xaxis_tickangle=-45)fig_sql.show()
Firestore ingestion benchmark
This slice compares the two Firestore write strategies directly so the throughput gap between BulkWriter and manual batch.set() stays visible across all three tiers.
Python example implementing the workflow described above.
Python example implementing the workflow described above.
fig_fs = go.Figure()for tier in ['large', 'medium', 'small']: df_t = df_fs[df_fs['tier'] == tier] if not df_t.empty: fig_fs.add_trace(go.Bar( x=df_t['method'], y=df_t['rate_raw'], name=tier.capitalize() ))fig_fs.update_layout( barmode='group', title='Firestore Ingestion Performance', xaxis_title='Ingestion Method', yaxis_title='Speed (Documents / Second)', # Linear scale used here as the variance is smaller template='plotly_dark', xaxis_tickangle=-45)fig_fs.show()
Cross-database transfer benchmark
This chart focuses on transfer paths that cross a service boundary, which makes the cost of to_dataframe(), staging, and BulkWriter fan-out easier to compare than in the full benchmark set.
Python example implementing the workflow described above.
Python example implementing the workflow described above.
fig_transfer = go.Figure()for tier in ['large', 'medium', 'small']: df_t = df_transfer[df_transfer['tier'] == tier] if not df_t.empty: fig_transfer.add_trace(go.Bar( x=df_t['method'], y=df_t['rate_raw'], name=tier.capitalize() ))fig_transfer.update_layout( barmode='group', title='Cross-Database Transfer Performance', xaxis_title='Transfer Path', yaxis_title='Speed (Rows / Second)', template='plotly_dark', xaxis_tickangle=-45)
Data exports benchmark
This export view separates extract_table, SQL cursor export, and Firestore document streaming so the final chart reflects outbound throughput rather than ingestion behavior.
Python example implementing the workflow described above.
Drops dbo.ohlcv_bench from SQL Server, removes the BigQuery staging and external tables, and purges the exports/ and staging/ GCS prefixes created during the benchmark. Firestore requires no explicit drop — documents are overwritten on the next set() call, so no collection scan or delete is needed.
Python example implementing the workflow described above.
with sql_pymssql() as conn: conn.cursor().execute('DROP TABLE IF EXISTS dbo.ohlcv_bench') conn.commit() print(' SQL Server: ohlcv_bench dropped')bq_client.delete_table(BQ_BENCH_TABLE, not_found_ok=True)bq_client.delete_table(f'{BQ_BENCH_TABLE}_ext', not_found_ok=True)print(' BigQuery: ohlcv_bench dropped')print(' Firestore: ohlcv_bench cleared')for blob in gcs_client.list_blobs(BUCKET_NAME, prefix='exports/'): blob.delete()for blob in gcs_client.list_blobs(BUCKET_NAME, prefix='staging/'): blob.delete()print(' GCS: exports/ and staging/ cleaned')if EXPORT_DIR.exists(): import shutil shutil.rmtree(EXPORT_DIR) print(f' Local: exports/ deleted')print(' Cleanup done')
Python Data Ingestion - SQL Server, BigQuery, Firestore Warnings
Never use INSERT in a loop for bulk loads
Row-by-row inserts to SQL Server are 50–200× slower than fast_executemany or BCP. Even for 2.5K rows the difference is measurable; at 750K it is the difference between seconds and minutes.
Correct pattern
Set cursor.fast_executemany = True before executemany(), or use bcp for the fastest local-to-SQL path. For DataFrames, to_sql(..., method='multi', chunksize=5000) is a reasonable middle ground.
Skipping WriteDisposition in BigQuery load jobs causes silent data duplication
The default disposition is WRITE_APPEND. Re-running a notebook cell without truncating first multiplies rows in the target table, corrupting benchmarks.
Correct pattern
Always pass job_config.write_disposition = bigquery.WriteDisposition.WRITE_TRUNCATE in benchmark cells, or wrap loads in an explicit table-delete before each run.
Streaming Firestore documents one by one exhausts your quota and is orders of magnitude slower
Each document.set() call is a separate HTTP request. At 75K documents this is thousands of round-trips and will hit the 1 req/s per-document soft limit.
Correct pattern
Use BulkWriter (preferred) or batch.set() with manual batch.commit() every 500 documents. BulkWriter handles retry and concurrency automatically.
Large GCS-to-SQL transfers that buffer through a DataFrame can exhaust memory
Loading 750K rows from GCS into a pd.DataFrame before writing to SQL Server peaks at 3–4× the raw file size in RAM. On containers with 4 GB limits this causes OOM kills.
Correct pattern
Stream the GCS object as BytesIO, parse in chunks (pd.read_csv(..., chunksize=10000)), and pipe each chunk to fast_executemany without materialising the full DataFrame.
Python Data Ingestion - SQL Server, BigQuery, Firestore Recommendations
Always set TQDM_DISABLE=1 at the top of the notebook to suppress pandas_gbq progress bars that pollute benchmark output.
Store credentials in environment variables (GOOGLE_APPLICATION_CREDENTIALS, SQL_SERVER_CONN), never hardcoded in notebook cells.
Use Parquet as the default interchange format — it is faster to read, smaller on disk, and carries schema metadata that prevents type-coercion errors on load.
Run benchmarks in the order small → medium → large (2.5K → 75K → 750K) so early failures do not contaminate timing data for larger tiers.
Persist benchmark results to a JSON file after each run so cross-session comparison is possible without re-executing expensive load jobs.
For BQ loads from GCS, prefer load_table_from_uri over load_table_from_file for files above 1 GB — GCS-native jobs avoid client-side upload latency.
When transferring between services (e.g. BQ → SQL Server), avoid materialising the full result as a DataFrame; stream in pages using the BigQuery Storage Read API or query_job.result().pages.
Clean up staging tables, GCS prefixes, and local export directories at the end of each benchmark session to avoid quota and cost surprises.
Python Data Ingestion - SQL Server, BigQuery, Firestore Troubleshooting
Match the failure to the narrowest boundary first: network, schema, write mode, buffer flush, memory, TLS, or region.
pyodbc.OperationalError: Login timeout expired
If pyodbc.OperationalError happens before the first query returns, verify the resolved SQL_SERVER_HOST or GCP_SQL_IP target and confirm that 1433 is reachable from the current runner.
Python example showing the connection target before pyodbc.connect().
import oshost = os.environ.get("SQL_SERVER_HOST") or os.environ.get("GCP_SQL_IP", "<missing>")print(f"sqlserver://{host}:1433")
sqlserver://<missing>:1433
fast_executemanyDataError on NULL
If fast_executemany fails on mixed data, normalize None values before executemany() so the driver does not have to infer conflicting SQL types from one column.
Python example normalizing nullable values before bulk insert parameter binding.
values = [1.25, None, 2.00]normalized = ["" if value is None else f"{value:.2f}" for value in values]print(normalized)
['1.25', '', '2.00']
notFound: Dataset
A notFound load-job failure usually means the fully qualified project.dataset.table does not exist in the expected project, so re-run the dataset creation step before loading data.
Python example printing the fully qualified BigQuery destination for validation.
If pandas_gbq.to_gbq() leaves too many rows behind, inspect the effective write mode and use if_exists='replace' when the benchmark or ETL run must be idempotent.
Python example making the intended pandas_gbq write mode explicit.
When BulkWriter appears to drop documents, treat bulk_writer.close() as mandatory and put it in finally so buffered writes flush even when earlier work fails.
Python example enforcing a finally flush guard for BulkWriter.
If the GCS-to-SQL path runs out of memory, stop materializing the whole file and switch to chunked pd.read_csv(..., chunksize=10000) ingestion so each batch stays bounded.
Python example illustrating chunk-oriented processing instead of one full-frame load.
When bcp fails during TLS negotiation, test the command line with -C RAW and your field terminator flags first so you can separate certificate-policy issues from CSV parsing issues.
PowerShell example composing the bcp flags that commonly fix TLS negotiation mismatches.
If load_table_from_uri() never finishes, compare the bucket region and dataset region directly because GCS and BigQuery must stay aligned for predictable load-job execution.
Python example checking whether the bucket and dataset regions match.