Data Flow Architecture

Quote

“As data accumulates, it begins to have gravity — it attracts services, applications, and more data toward it. Moving compute to data is almost always cheaper than moving data to compute.”

Dave McCrory (coined the term “data gravity”)

The Complete Data Flow Topology

graph TD
    subgraph External
        API[External APIs<br>yfinance, FRED, Finnhub]
        GHA[GitHub Actions<br>CI/CD]
    end

    subgraph Workstation[Local Workstation]
        DEV[Developer Machine]
    end

    subgraph GCP[Google Cloud Platform]
        GCS[Cloud Storage<br>gs://data-lake]
        BQ[BigQuery<br>Warehouse]
        CR[Cloud Run<br>Jobs/Services]
        PS[Pub/Sub<br>Events]
        FS[Firestore<br>Real-time]
    end

    subgraph VM[Compute Engine VMs]
        SQL[SQL Server<br>bronze → silver → gold]
        AF[Airflow<br>Orchestration]
        DD[Datadog Agent<br>Monitoring]
    end

    API -->|Python loaders| SQL
    API -->|Python fetch| GCS
    SQL -->|bcp export → gsutil| GCS
    GCS -->|bq load| BQ
    SQL -->|CDC| PS
    PS -->|streaming insert| BQ
    PS -->|Python consumer| FS
    DEV -->|IAP tunnel| SQL
    DEV -->|gcloud scp / rsync| VM
    DEV -->|gcloud storage cp| GCS
    GHA -->|WIF auth| GCP
    AF -->|orchestrates| SQL
    AF -->|orchestrates| BQ
    CR -->|scheduled jobs| SQL

    style API fill:#e8b84d,stroke:#333,color:#000
    style GCS fill:#4285f4,stroke:#333,color:#fff
    style BQ fill:#669df6,stroke:#333,color:#fff
    style SQL fill:#cc4125,stroke:#333,color:#fff
    style AF fill:#017cee,stroke:#333,color:#fff
    style PS fill:#34a853,stroke:#333,color:#fff
    style FS fill:#ff9100,stroke:#333,color:#fff

Flow inventory — every data movement in the stack

FlowSourceDestinationToolFormatFrequencyVault Reference
API ingestionExternal APIsSQL Server bronzePython (pyodbc)JSON → SQL INSERTDaily (Airflow)bronze-layer-loading > Strategy 1: Truncate & Reload (Most Loaders)
OHLCV mergeExternal APIsSQL Server bronzePython (pyodbc)JSON → SQL MERGEDaily (Airflow)bronze-layer-loading > Strategy 2: Merge (OHLCV Only)
Bronze → SilverSQL Server bronzeSQL Server silverPython transformsIn-databaseDaily (Airflow)medallion-architecture > Silver (Cleaned)
Silver → GoldSQL Server silverSQL Server goldPython transformsIn-databaseDaily (Airflow)medallion-architecture > Gold (Analytics)
SQL → BigQuerySQL Server goldBigQuerybcp → GCS → bq loadCSV/ParquetDailySee cross-database join below
CDC streamingSQL ServerPub/Sub → BigQueryCDC + PythonJSON eventsNear-real-timesql-server-change-tracking > CDC → Pub/Sub — streaming changes to GCP
CDC to FirestoreSQL ServerFirestoreCDC + PythonJSON docsEvent-drivensql-server-change-tracking > CDC → Firestore — push dimension changes to real-time store
GCS → BigQueryCloud StorageBigQuerybq loadParquet/CSVOn-demanddata-loading-and-export > bq load —source_format=PARQUET — load Parquet from GCS (recommended)
File transferLocalGCE VMgcloud scp / rsyncAnyAd-hocdata-transfer > gcloud compute scp — push and pull files to/from GCE VMs
File uploadLocalGCSgcloud storage cpAnyAd-hocdata-transfer > gcloud storage — modern replacement for gsutil (20-94% faster)
OrchestrationAirflowAll systemsDAG tasksN/AScheduledairflow-dag-patterns > Medallion Architecture DAG — Bronze to Silver to Gold

Transfer Method Decision Matrix

“I need to move data from X to Y — which tool?”

SourceDestinationVolumeBest ToolWhyReference
Local fileGCE VM< 1 GBgcloud compute scpSimple, IAP-integrateddata-transfer > gcloud compute scp — push and pull files to/from GCE VMs
Local fileGCE VM> 1 GBrsync -avzP through IAPResume, delta, compressiondata-transfer > rsync through IAP tunnel — transferring to GCE VMs with no public IP
Local fileGCSAnygcloud storage cpParallel composite upload, resumabledata-transfer > gcloud storage — modern replacement for gsutil (20-94% faster)
GCSBigQueryAnybq loadNative, no intermediate stepdata-loading-and-export > bq load —source_format=PARQUET — load Parquet from GCS (recommended)
BigQueryGCSAnybq extractNative export with compressiondata-loading-and-export > Exporting BigQuery Data to GCS
JSON/CSVSQL Server< 100K rowspyodbc fast_executemanyTransactional, Python-nativesql-server-loading-patterns > cursor.fast_executemany = True — batch mode activation
JSON/CSVSQL Server> 1M rowsbcp bulk loadFastest path, minimal loggingsql-server-loading-patterns > bcp BULK LOAD — command-line syntax
SQL ServerCSV fileAnybcp queryoutMaximum throughputdata-transfer > bcp queryout — export a query result to CSV
GCS ↔ GCSSame regionAnygsutil cp gs:// gs://Server-side, zero egressdata-transfer > gsutil cp gs:// gs:// — server-side copy between GCS buckets
Directory syncLocal ↔ GCSOngoinggsutil rsync / gcloud storage rsyncDelta sync, delete supportdata-transfer > gsutil rsync — delta sync to Cloud Storage
VM ↔ VMSame VPCAnyrsync over private IPNo IAP needed, direct pathdata-transfer > rsync -avzP over SSH — local to remote and back
VM ↔ VMCross-VPCAnyrsync through IAPIAP for secure cross-VPCiap-tunneling

Format Selection by Scenario

When to use CSV vs JSON vs Parquet vs Avro. For the deep codec comparison with benchmarks, see serialization-formats.

ScenarioFormatCompressionWhy
API response landing (bronze)JSONNone (small files)Preserves source structure exactly
Pipeline intermediate filesParquetsnappy (default)Columnar, fast reads, schema embedded
BigQuery loading from GCSParquetsnappyNative BQ support, schema auto-detect
SQL Server bcp exportCSVgzip or zstdbcp native format, universal
Long-term archive in GCSParquetzstd -19Maximum compression for cold storage
Streaming / messaging (Pub/Sub)JSONNoneHuman-readable, schema-flexible
Cross-system data contractAvrodeflateSchema evolution, compact binary

Format selection rule of thumb

  • Landing zone (bronze): Keep the source format (JSON from APIs, CSV from bcp)
  • Processing (silver/gold): Parquet with snappy — columnar reads, schema enforcement
  • Warehouse (BigQuery): Parquet for bulk loads, JSON for streaming inserts
  • Messaging (Pub/Sub): JSON — schema validation at the consumer, not the broker

For compression algorithm selection (gzip vs zstd vs snappy), see compression > Compression strategy matrix — choosing the right algorithm for data pipelines.


Push vs Pull Architecture

Three models for data flow direction. Most production stacks use all three.

graph LR
    subgraph Pull["Pull Model"]
        C1[Consumer] -->|requests data| S1[Source]
    end
    subgraph Push["Push Model"]
        S2[Source] -->|sends on change| C2[Consumer]
    end
    subgraph Staged["Staged Model"]
        S3[Source] -->|writes to| I[Intermediate<br>Storage] -->|reads from| C3[Consumer]
    end

    style Pull fill:#1a1a2e,stroke:#4285f4,color:#fff
    style Push fill:#1a1a2e,stroke:#34a853,color:#fff
    style Staged fill:#1a1a2e,stroke:#e8b84d,color:#fff
ModelHow It WorksStack ExampleBest For
PullConsumer requests data when neededBigQuery query, SELECT from SQL Server, API callLow coupling, consumer controls timing
PushProducer sends data when it changesCDC → Pub/Sub, Firestore trigger, webhookLow latency, event-driven reactions
StagedProducer writes to storage, consumer reads at own paceAPI → JSON → GCS → bq loadDecoupled, fault-tolerant, replayable

When to choose each model

  • Pull when the consumer needs data on-demand and can tolerate latency (dashboards, ad-hoc queries, backfills)
  • Push when changes must propagate within seconds (dimension updates to Firestore, alerting on anomalies)
  • Staged when source and destination have different availability, speed, or schema requirements (the default for batch pipelines — producer and consumer never need to be online simultaneously)

Batch vs Streaming vs Micro-Batch

For the full streaming architecture theory (Lambda, Kappa, event sourcing, windowing), see streaming-architecture.

PatternLatencyTool in StackUse Case
Batch (scheduled)HoursAirflow DAG → Python → SQL Server / BigQueryDaily pipeline, backfills, full refresh
Micro-batchMinutesCloud Scheduler → Cloud Run Job5-minute price snapshots (pulse)
StreamingSecondsCDC → Pub/Sub → BigQuery streaming insertDimension change propagation
graph TD
    subgraph Batch["Batch (Daily)"]
        B1[Airflow Scheduler] -->|triggers| B2[Python Loaders]
        B2 -->|INSERT/MERGE| B3[SQL Server]
        B3 -->|bcp export| B4[GCS]
        B4 -->|bq load| B5[BigQuery]
    end

    subgraph Micro["Micro-Batch (5 min)"]
        M1[Cloud Scheduler] -->|triggers| M2[Cloud Run Job]
        M2 -->|fetch + INSERT| M3[SQL Server pulse]
    end

    subgraph Stream["Streaming (seconds)"]
        ST1[SQL Server CDC] -->|change events| ST2[Pub/Sub]
        ST2 -->|streaming insert| ST3[BigQuery]
        ST2 -->|consumer| ST4[Firestore]
    end

    style Batch fill:#1a1a2e,stroke:#4285f4,color:#fff
    style Micro fill:#1a1a2e,stroke:#e8b84d,color:#fff
    style Stream fill:#1a1a2e,stroke:#34a853,color:#fff

Choosing the right pattern

  • Batch is correct for 90% of data engineering workloads. Don’t add streaming complexity unless you have a latency requirement under 5 minutes.
  • Micro-batch (Cloud Run on a schedule) is the sweet spot for “near-real-time” without the operational cost of streaming infrastructure.
  • Streaming is justified only when: (a) downstream consumers need sub-minute data, AND (b) the source supports change capture.

See streaming-architecture > Streaming vs Batch Decision Matrix for the full decision framework.

Default to Batch, Escalate Deliberately

Start every new pipeline as a daily Airflow DAG. If stakeholders request faster data, move to micro-batch (Cloud Scheduler → Cloud Run) — this delivers near-real-time refresh with no streaming infrastructure. Only introduce CDC + Pub/Sub streaming when a documented latency SLA of under 5 minutes cannot be met by micro-batch and the source system supports change capture.


The Cross-Database Join Problem

“I need data from SQL Server AND BigQuery in the same query.” There is no direct connector between them. Three options:

Option A: Export BigQuery → load into SQL Server (small datasets)

graph LR
    BQ[BigQuery] -->|bq extract| GCS[GCS bucket]
    GCS -->|gsutil cp| VM[GCE VM]
    VM -->|bcp in| SQL[SQL Server]

    style BQ fill:#669df6,stroke:#333,color:#fff
    style SQL fill:#cc4125,stroke:#333,color:#fff

Best for < 1M rows. Use when SQL Server has the complex logic and BigQuery has a small reference table.

Option B: Export SQL Server → load into BigQuery (analytical queries)

graph LR
    SQL[SQL Server] -->|bcp queryout| CSV[CSV on VM]
    CSV -->|gcloud storage cp| GCS[GCS bucket]
    GCS -->|bq load| BQ[BigQuery]

    style SQL fill:#cc4125,stroke:#333,color:#fff
    style BQ fill:#669df6,stroke:#333,color:#fff

Best for analytical queries over large datasets. Use when BigQuery is the analytical engine and SQL Server has the source data.

Option C: Pull both into Python DataFrames (ad-hoc analysis)

graph LR
    SQL[SQL Server] -->|pyodbc / pd.read_sql| PY[Python<br>pandas/polars]
    BQ[BigQuery] -->|bigquery.Client| PY
    PY -->|merge / join| R[Result DataFrame]

    style SQL fill:#cc4125,stroke:#333,color:#fff
    style BQ fill:#669df6,stroke:#333,color:#fff
    style PY fill:#306998,stroke:#333,color:#fff

Best for ad-hoc analysis and small-to-medium joins. Use when both datasets fit in memory.

Cross-Database Join Decision

Move the smaller, more static dataset to where the larger, more dynamic dataset lives. If BigQuery has 10B rows and SQL Server has 1K dimension rows, export the dimension to BigQuery — never pull 10B rows to SQL Server. If both datasets are small, Option C (Python) is the fastest path to an answer.


Data Flow Anti-Patterns

Data Flow Anti-Patterns

Anti-PatternProblemFix
Laptop as ETL serverDoesn’t scale, single point of failure, network bottleneckRun pipelines on GCE VMs or Cloud Run
Direct SQL Server ↔ BigQueryNo connector exists — people waste hours lookingUse the staged pattern: bcp → GCS → bq load
Uncompressed network transfersWastes bandwidth, 3-10x slowerAlways compress: rsync -z, gzip, zstd
No intermediate storageIf destination fails, restart from sourceStage in GCS first — replay without re-fetching
Mixed push and pull for same flowCDC to Pub/Sub AND a batch pull = duplicatesChoose one: event-driven OR batch, not both
No source-destination validationRow count mismatches go unnoticedCompare COUNT(*) after every load — see idempotent-pipeline-design
Loading directly to productionNo validation, no rollbackAlways load to staging first — see sql-server-loading-patterns > Loading Directly to Production — no staging, no validation

Safe Data Flow Patterns

Run all pipeline jobs on GCE VMs or Cloud Run — never on a local machine. Always stage data in GCS before loading to BigQuery (bcp queryoutgcloud storage cpbq load). Compress all network transfers with rsync -z or zstd. Always compare COUNT(*) source vs destination after every load. Use a single flow model per dataset (either CDC streaming or batch — not both) and load to a staging table first, then swap or merge into production.