Scraping is only half the job. Once your spider has pulled thousands of products, listings, or reviews, that data has to land somewhere you can query, deduplicate, and join against the rest of your business — and for a lot of teams that somewhere is Microsoft SQL Server (or its cloud sibling, Azure SQL). This article walks through the practical ways to insert web scraping results into SQL Server, from a plain INSERT loop to bulk loading, SSIS, and ORM-driven pipelines, with the trade-offs of each so you can pick the right one for your volume.
If you're still deciding where to land data at all, relational SQL Server suits structured, dedup-heavy datasets; for schemaless or high-write JSON you might weigh a document database instead. Assuming SQL Server, here are your options.
First, one rule: parameterize everything
Older tutorials — including the one this article replaces — build SQL by string-concatenating scraped values straight into the query. Don't. Scraped text is hostile input: a product title containing an apostrophe will break your statement, and a malicious value can rewrite it entirely (SQL injection). Every method below uses parameterized queries or a bulk API that binds values safely. This isn't optional polish; it's the difference between a pipeline that survives real-world data and one that corrupts on the first quirky record.
The methods at a glance
| Method | Best for | Throughput | Complexity |
|---|---|---|---|
Row-by-row INSERT |
tiny jobs, prototypes | low | trivial |
Batched executemany (fast_executemany) |
most Python scrapers | high | low |
BULK INSERT / bcp from file |
large one-shot loads | very high | medium |
SqlBulkCopy (.NET) |
C#/.NET scrapers | very high | low |
| SSIS package | scheduled enterprise ETL | very high | high |
| ORM pipeline (SQLAlchemy / EF) | app-integrated pipelines | medium | low |
The rest of this section shows each in practice.
1. Row-by-row INSERT
The simplest approach: connect, then run one parameterized INSERT per record. In Python the standard driver is pyodbc with Microsoft's ODBC Driver 18 for SQL Server.
import pyodbc
conn = pyodbc.connect(
"DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=localhost;DATABASE=scrapes;UID=etl;PWD=***;"
"Encrypt=yes;TrustServerCertificate=yes;"
)
cur = conn.cursor()
for item in items:
cur.execute(
"INSERT INTO products (sku, title, price, scraped_at) "
"VALUES (?, ?, ?, SYSUTCDATETIME())",
item["sku"], item["title"], item["price"],
)
conn.commit()
Pros: trivial, easy to reason about. Cons: one network round trip per row — fine for a few hundred records, painfully slow for hundreds of thousands. Use it for prototypes and small daily deltas only.
2. Batched executemany — the practical default
For most scrapers this is the sweet spot. Collect rows into a list and hand them to executemany(), and crucially set fast_executemany = True on the cursor. That flag tells pyodbc to send the whole batch in one parameter array instead of a round trip per row — often a 10–100x speedup for the same code.
cur.fast_executemany = True
rows = [(i["sku"], i["title"], i["price"]) for i in items]
cur.executemany(
"INSERT INTO products (sku, title, price) VALUES (?, ?, ?)",
rows,
)
conn.commit()
Chunk very large loads (say, 5,000–10,000 rows per batch) so you're not holding a giant transaction open, and wrap each batch so a failure rolls back cleanly and retries. For the majority of scraping data pipelines, batched executemany is all you need.
3. BULK INSERT and bcp — for big one-shot loads
When you've scraped millions of rows and can stage them as a file, SQL Server's native bulk paths are the fastest option. Write the results to CSV (or, better, a delimited/UTF-8 file), then load it server-side:
BULK INSERT staging.products
FROM '/data/products_2026_07_11.csv'
WITH (
FORMAT = 'CSV',
FIRSTROW = 2, -- skip header
FIELDTERMINATOR = ',',
ROWTERMINATOR = '0x0a',
TABLOCK -- minimal logging, faster
);
The bcp command-line utility does the same from outside SQL, which is handy in shell-driven ETL. Pros: the highest throughput available; minimal transaction logging with TABLOCK. Cons: the file must be readable by the SQL Server service account, quoting/encoding has to be exactly right, and it's a batch operation, not a streaming one. Ideal for periodic full refreshes.
4. SqlBulkCopy — for C#/.NET scrapers
If your crawler is written in C# (see C# web scraping), you don't need files at all. SqlBulkCopy streams an in-memory DataTable or reader straight into a table at bulk speed:
using var bulk = new SqlBulkCopy(connection)
{
DestinationTableName = "staging.products",
BatchSize = 10_000,
};
bulk.WriteToServer(dataTable);
It's the .NET equivalent of bcp with none of the file plumbing — the go-to for high-volume .NET pipelines.
5. SSIS packages — scheduled enterprise ETL
SQL Server Integration Services is the heavyweight option: a visual ETL tool where you build a data flow (source → transforms → destination), schedule it in SQL Agent, and get logging, error routing, and lookups out of the box. It shines when the load is one step in a larger governed pipeline — cleaning, type conversion, joining reference data, then loading — and when non-developers need to maintain it. For a lone scraper it's overkill; for an established data team already running SSIS, dropping scraped files into an existing package is the path of least resistance.
6. ORM pipeline (SQLAlchemy / Scrapy item pipeline)
If your project already uses an ORM, let it own the writes. In a Scrapy project, the natural home is an item pipeline that buffers items and flushes them in batches:
# pipelines.py
from sqlalchemy import create_engine
class SqlServerPipeline:
def open_spider(self, spider):
self.engine = create_engine(
"mssql+pyodbc://etl:***@localhost/scrapes"
"?driver=ODBC+Driver+18+for+SQL+Server",
fast_executemany=True,
)
self.buffer = []
def process_item(self, item, spider):
self.buffer.append(dict(item))
if len(self.buffer) >= 5000:
self._flush()
return item
def _flush(self):
if not self.buffer:
return
with self.engine.begin() as conn:
conn.execute(products_table.insert(), self.buffer)
self.buffer.clear()
def close_spider(self, spider):
self._flush()
Passing fast_executemany=True to the engine gives you bulk-ish speed while keeping ORM ergonomics. In .NET, Entity Framework Core plays the same role. Pros: clean, typed, integrated with app code. Cons: slower than raw bulk loading at extreme volumes, so batch and don't insert one item at a time.
Load into a staging table, then MERGE
Whatever method you choose, the pattern that keeps a recurring pipeline sane is stage then merge. Bulk-load the raw scrape into a staging table with loose types, then run a single set-based MERGE (upsert) into the clean production table keyed on a natural identifier like SKU or URL:
MERGE INTO dbo.products AS tgt
USING staging.products AS src
ON tgt.sku = src.sku
WHEN MATCHED AND (tgt.price <> src.price) THEN
UPDATE SET tgt.price = src.price,
tgt.updated_at = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (sku, title, price, created_at)
VALUES (src.sku, src.title, src.price, SYSUTCDATETIME());
This makes the load idempotent — re-running the same scrape doesn't create duplicates, it updates changed rows and inserts genuinely new ones. It's exactly what you want for recurring jobs like competitor price monitoring, where you re-scrape the same catalog daily and only care about what changed.
Schema and reliability tips
- Use the right types.
NVARCHARfor text (scraped data is Unicode — emoji, accents, non-Latin scripts will appear),DECIMALfor money,DATETIME2/DATETIMEOFFSETfor timestamps. Store ascraped_atand, if relevant, thesource_url. - Keep a raw column. Landing the original JSON/HTML snippet in an
NVARCHAR(MAX)column lets you re-parse later without re-scraping when your extraction logic improves. SQL Server can query JSON directly withOPENJSON/JSON_VALUE. - Add a unique key for dedup. A unique index on the natural key powers the
MERGEand stops duplicates at the database level. - Batch and retry. Wrap batches in transactions, retry on transient errors (deadlocks, timeouts — common under load, and expected on Azure SQL), and use exponential backoff.
- Mind indexes during bulk loads. For very large loads it's often faster to drop non-clustered indexes, load, then rebuild.
- Azure SQL is the same story. Use ODBC Driver 18, keep
Encrypt=yes, and expect transient-fault retries;SqlBulkCopy,BULK INSERT(from Azure Blob), andexecutemanyall work.
Which one should you use?
- A few hundred rows on an ad-hoc basis → row-by-row INSERT.
- A normal recurring scraper → batched
executemany/ ORM pipeline, ideally into a staging table with aMERGE. - Millions of rows, periodic refresh →
BULK INSERT/bcp/SqlBulkCopy. - Part of a governed enterprise ETL flow → SSIS.
Summary
Getting scraped data into SQL Server ranges from a one-line parameterized INSERT to full bulk loading, and the right choice is mostly about volume and how often the job runs. For most scrapers, batched executemany with fast_executemany=True — landing into a staging table and upserting with MERGE — gives you speed, deduplication, and idempotency without much code. Reserve BULK INSERT/bcp/SqlBulkCopy for heavy loads and SSIS for established ETL. Above all, parameterize every value; scraped input is untrusted by definition. If you'd rather receive clean, ready-to-load data than build and babysit the pipeline yourself, scraping.pro offers extraction and delivery as a data-as-a-service engagement, dropping structured results straight into the database or format you specify.