Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 17

Ingesting Raw Data to a Postgres Bronze Zone

Recommended resources

Welcome back! In our previous lessons, we built robust functions to extract data from APIs and large CSV files. We now have our raw data in hand, validated and chunked. The next crucial step is to land this data in our data warehouse, establishing the first foundational layer of our pipeline.

This lesson focuses on loading that raw data into a 'bronze' landing zone within your PostgreSQL database. We will explore the purpose and design of this bronze layer within the Medallion architecture. More importantly, we'll dive deep into the various strategies for loading data into PostgreSQL from Python, moving from simple methods to highly performant bulk-loading techniques that are essential for any serious data engineering workflow.

The Bronze Layer: Your Raw Data Vault

Before we write any code, let's establish our destination. In the Medallion architecture (which we touched on in Module 1), the first stop for all incoming data is the Bronze layer. Think of this as a raw data vault. Its primary purpose is to provide a persistent, immutable, and queryable copy of the source data as it was received, with minimal to no transformation.

The key principles of the Bronze layer are:

  • Raw Fidelity: The structure and data types should mirror the source as closely as possible.
  • Append-Only: Data is typically appended, not updated, preserving a full historical record.
  • Minimal Transformation: The only changes made are those necessary for storage (e.g., standardizing file formats). No business logic or cleaning is applied.

This approach is incredibly valuable because if there's ever an error in your downstream transformation logic, you can always rebuild your Silver and Gold layers directly from the Bronze data without having to re-query the original source systems, which may be slow, expensive, or no longer have the historical data.

The diagram below illustrates this concept, showing data from various external sources being loaded into staging tables within the Bronze layer.

This diagram shows various raw data sources (Google Ads, Segment, Salesforce, Postgres) landing in staging tables within the Bronze layer. This is exactly the pattern we will be implementing.

To achieve this, we'll create tables in Postgres designed to be flexible landing zones. A common practice is to define columns with generous data types like TEXT or, for nested API data, JSONB. This prevents the entire loading process from failing if a source system unexpectedly sends a string instead of a number.


Connecting to PostgreSQL with psycopg2

To get our Python scripts to talk to our PostgreSQL database, we'll use psycopg2, the most popular PostgreSQL database adapter for Python. Your proficiency with Python and PostgreSQL means you'll find the process familiar, but it's worth reviewing the best practices for managing connections and transactions to ensure our pipeline is robust.

The following video from techTFQ provides a concise and clear guide to the fundamentals of using psycopg2.

Connect to PostgreSQL from Python (Using SQL in Python) | Python to PostgreSQL

Please watch this video to get a handle on the core psycopg2 workflow. Focus on these key segments:

Connecting to the database: Pay attention to the connection parameters and how a connection object is created and closed. Using a try...except block: This demonstrates basic error handling for connection failures. Using context managers (with statements): This is the most important part. The video explains how with statements help manage transactions (commit/rollback) and automatically close cursors. This is the standard, modern way to handle database interactions in Python, and you should adopt this pattern.

The key takeaway is to always wrap your connection and cursor creation in with statements. This simplifies your code and makes it far more resilient by automatically handling resource cleanup and transaction states, even when errors occur.


From Slow Inserts to Blazing-Fast COPY

Now that we can connect, how do we get the data in? A naive approach might be to loop through our data and execute an INSERT statement for each row. Given your experience with SQL, you likely intuit that this would be extremely slow due to the high number of database round-trips and transaction overhead.

For a data pipeline, loading performance is critical. We need a method that can handle hundreds of thousands or millions of rows efficiently. This is where we go beyond basic INSERTs.

The article "Fastest Way to Load Data Into PostgreSQL Using Python" by Haki Benita is an outstanding, in-depth benchmark of various loading techniques. It systematically compares different methods, revealing the dramatic performance differences.

Fastest Way to Load Data Into PostgreSQL Using Python | Haki Benita

This article is a comprehensive guide to optimizing data loading into PostgreSQL. As you read, focus on the evolution of the techniques and the rationale behind the performance gains at each step.

Please read through the following sections to understand the performance hierarchy of different loading methods: Start by reading the Setup section to understand the dataset and the goal. Notice the creation of an UNLOGGED table—this is a performance trick we'll revisit. Next, review "Insert Rows One by One" to see the baseline performance. This is the pattern we want to avoid. Skim through the executemany sections. The key takeaway here is that, contrary to what its name suggests, executemany offers little to no performance benefit for psycopg2. Now, focus on "Execute Batch". This function from psycopg2.extras is the first real performance leap, as it reduces server round-trips. Read the section "Execute Values". This is another high-performance helper that is often even faster than execute_batch. Finally, and most importantly, study the "Copy" section. This introduces PostgreSQL's COPY command, the undisputed champion of bulk loading. Pay close attention to the Python implementation using cursor.copy_from with an in-memory io.StringIO object. Conclude by reviewing the Results Summary table and the final Summary to cement your understanding of when to use each method.

As the article makes clear, for raw bulk-loading speed, nothing beats the COPY command. It's a native PostgreSQL utility designed specifically for this purpose, streaming data directly into a table and bypassing much of the overhead associated with SQL INSERT statements. The psycopg2 library gives us programmatic access to this powerful tool via the cursor.copy_from() method.


Practice: Loading a DataFrame into the Bronze Layer

Let's synthesize these concepts into a practical Python script. We'll take a DataFrame (which we might have created from a CSV file in the previous lesson) and load it into a stg_sales bronze table using the highly efficient COPY method.

First, let's define our target table in Postgres. Note the use of TEXT for flexibility and the addition of a _loaded_at metadata column.

CREATE TABLE stg_sales (
    order_id TEXT,
    product_name TEXT,
    quantity TEXT,
    price_per_unit TEXT,
    order_date TEXT,
    _loaded_at TIMESTAMP WITH TIME ZONE DEFAULT (NOW() AT TIME ZONE 'utc')
);

Now, here is the Python function to perform the load. This pattern is robust, performant, and clean. It uses an in-memory buffer (StringIO) to pass the DataFrame data to copy_from, avoiding the need to write a temporary file to disk.

import pandas as pd
import psycopg2
from io import StringIO
import logging

def load_df_to_postgres(df: pd.DataFrame, table_name: str, db_params: dict):
    """
    Loads a Pandas DataFrame into a PostgreSQL table using the fast COPY method.

    Args:
        df (pd.DataFrame): The DataFrame to load.
        table_name (str): The name of the target table in PostgreSQL.
        db_params (dict): Database connection parameters.
    """



    # Create an in-memory file-like object
    buffer = StringIO()
    



    # Write DataFrame to the buffer as a CSV, without header and index
    # The separator is a tab because it's less likely to appear in data than a comma
    df.to_csv(buffer, index=False, header=False, sep='\t')
    



    # Reset the buffer's file pointer to the beginning
    buffer.seek(0)

    try:



        # Use a 'with' statement for the connection
        with psycopg2.connect(**db_params) as conn:



            # Use a 'with' statement for the cursor
            with conn.cursor() as cursor:
                logging.info(f"Starting COPY process for table {table_name}...")
                



                # Use copy_expert for more control, specifying the columns
                # This assumes the DataFrame column order matches the table column order
                columns = ','.join(df.columns)
                sql_query = f"COPY {table_name} ({columns}) FROM STDIN WITH (FORMAT CSV, DELIMITER E'\\t')"
                



                # The copy_expert method is used here to stream data from the buffer
                cursor.copy_expert(sql=sql_query, file=buffer)
                



                # The 'with conn' block will automatically commit on successful exit
                logging.info(f"Successfully loaded {len(df)} rows into {table_name}.")

    except (Exception, psycopg2.DatabaseError) as error:
        logging.error(f"Error loading data to PostgreSQL: {error}")



        # The 'with conn' block will automatically roll back on error
        raise




# Example Usage:
# Assume 'my_sales_df' is a pandas DataFrame you've already created
# and 'db_connection_params' is a dictionary with your DB credentials.




# db_connection_params = {
#     "host": "localhost",
#     "database": "your_db",
#     "user": "your_user",
#     "password": "your_password",
#     "port": "5432"
# }
# load_df_to_postgres(my_sales_df, 'stg_sales', db_connection_params)

This function is a powerful and reusable component for your ingestion scripts. It encapsulates the best practice of using COPY for performance while leveraging the data manipulation capabilities of Pandas.


Conclusion

In this lesson, we bridged the gap between data extraction and storage, a core task in data engineering. You've learned how to implement the first critical stage of the Medallion architecture by loading raw data into a bronze landing zone in PostgreSQL.

Here are the key takeaways:

  • The Bronze Layer is Your Foundation: It stores a faithful, raw copy of your source data, enabling pipeline replays and providing a durable historical record.
  • Design for Ingestion: Bronze tables should use flexible data types (TEXT, JSONB) to prevent load failures and include metadata columns like _loaded_at.
  • Performance is Key for Loading: Avoid row-by-row INSERTs. For bulk loads in Python, PostgreSQL's COPY command is the fastest method, accessible via psycopg2's copy_from or copy_expert methods.
  • The DataFrame -> StringIO -> COPY Pattern: This is a highly efficient and elegant way to move data from memory directly into PostgreSQL without intermediate disk writes.

We have successfully implemented a simple, append-only loading strategy. However, real-world sources often require more sophisticated logic. For example, how do you handle updates to existing records, or avoid inserting duplicates if you re-run an ingestion job? In our next lesson, we will address exactly this by implementing an idempotent "upsert" loading pattern using INSERT ... ON CONFLICT.