Have you ever spent hours meticulously optimizing your Python application to fetch external data asynchronously, perhaps using aiohttp and asyncio.Semaphore, only to discover a new, infuriating bottleneck right when that data hits your database? It's a classic trap: we gain incredible speed by making network calls non-blocking, but then we throw it all away by hitting a synchronous database driver. For working developers and data engineers, this synchronous database I/O can quickly nullify all your hard-won asyncio gains, turning your high-throughput pipeline into a traffic jam at the last mile. This post is for you if you're looking to extend the benefits of asyncio directly to your database layer, ensuring truly non-blocking, high-throughput data ingestion from dynamic external feeds. We're going to build a resilient pipeline that not only fetches data asynchronously but also ingests it into a PostgreSQL database using SQLAlchemy 2.0's native async capabilities and asyncpg, letting your entire system breathe and scale.
Key Takeaways
- Asynchronous SQLAlchemy 2.0 with
asyncpgis essential for non-blocking database I/O inasyncioapplications, complementing async API fetching. - Efficient connection pooling and transaction management through
AsyncEngineandAsyncSessionare critical for production async database services. - Implementing idempotent batch inserts via
ON CONFLICTclauses prevents data duplication and significantly optimizes ingestion performance. - Strategic error handling, including retries for transient database issues and graceful handling of network failures, enhances pipeline resilience.
- Leveraging SQLAlchemy's ORM for schema definition simplifies data modeling while maintaining full async compatibility and type safety.
The Problem: The Database as the Bottleneck
In a previous post, Beyond Blocking Calls: Architecting a Resilient Async API Fetcher with `asyncio` and Semaphores, I walked through building a robust asynchronous API fetcher. It was great for pulling data concurrently from multiple sources without blocking the event loop. The next logical step for many of these applications is to persist that data. But here's where the problem often arises: traditional database drivers like `psycopg2` are inherently synchronous. When your `asyncio` application makes a call to `session.add()` and `session.commit()` with a synchronous driver, the entire event loop pauses, waiting for the database operation to complete. This means that even if you've fetched thousands of items concurrently, they all queue up at the database write, turning your blazing-fast async pipeline into a single-threaded bottleneck. Our challenge is to make the database interaction just as asynchronous and non-blocking as our data fetching.
Data and Sources
For this demonstration, we'll be ingesting recent engineering blog posts from GitHub into a PostgreSQL database. This provides a real-world, dynamic data source that updates regularly.
- GitHub Engineering RSS Feed: https://github.blog/engineering/feed/
feedparserdocumentation: https://pypi.org/project/feedparser/aiohttpdocumentation: https://docs.aiohttp.org/en/stable/- SQLAlchemy 2.0 Asyncio documentation: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
asyncpgdocumentation: https://magicstack.github.io/asyncpg/current/- PostgreSQL Database: (A local instance, ideally via Docker, for demonstration. We'll connect using a standard connection string.)
- Python
asynciodocumentation: https://docs.python.org/3/library/asyncio.html
Data accessed on 2023-10-27.
Step 1 — Laying the Async Foundation: SQLAlchemy Models and Engine Setup
The first hurdle is defining our data schema and configuring SQLAlchemy to use an asynchronous driver. This step solves the sub-problem of translating our conceptual data model (an article with a title, link, summary, and publish date) into a database table, and more importantly, setting up an asynchronous connection to PostgreSQL. We'll use SQLAlchemy 2.0's declarative ORM with type hints and its `AsyncEngine` and `async_sessionmaker` for connection management.
Here's how we define our `Article` model and set up the asynchronous engine:
import os
import asyncio
import datetime
from typing import List, Optional
import feedparser
import aiohttp
from sqlalchemy import Column, Integer, String, Text, DateTime, func, UniqueConstraint
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.dialects.postgresql import insert as pg_insert
# --- Database Setup ---
class Base(DeclarativeBase):
pass
class Article(Base):
__tablename__ = "articles"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(512), nullable=False)
link: Mapped[str] = mapped_column(String(512), nullable=False, unique=True)
summary: Mapped[Optional[str]] = mapped_column(Text)
published: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ingested_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("link", name="uq_article_link"),
)
def __repr__(self):
return f"<Article(title='{self.title[:30]}...', link='{self.link}')>"
async def create_db_and_tables(engine):
"""Creates database tables if they don't exist."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("Database tables created or already exist.")
In this snippet, `Article` defines our table structure, with `Mapped` and `mapped_column` providing type safety and database column mapping. The `link` field is marked as `unique=True`, which will be crucial for our idempotent inserts. The `create_async_engine` function is the core of our async database connection. Notice the `postgresql+asyncpg://` prefix in the connection string – this explicitly tells SQLAlchemy to use the `asyncpg` driver, which is built for `asyncio`. The `async_sessionmaker` then provides an asynchronous session factory, managing connection pooling and transaction scope for us.
Step 2 — Resilient Async Feed Fetching
With our database models ready, the next challenge is to robustly fetch and parse the external RSS feed in an asynchronous manner. This step solves the sub-problem of acquiring the raw data efficiently and handling potential network failures gracefully. We'll leverage `aiohttp` for non-blocking HTTP requests and `feedparser` for parsing the XML content, ensuring our data acquisition doesn't block our event loop.
# --- Feed Fetching ---
async def fetch_feed_data(url: str, session: aiohttp.ClientSession) -> List[dict]:
"""Fetches and parses an RSS feed asynchronously."""
try:
async with session.get(url, timeout=10) as response: