Skip to main content

Frameshift — load a pandas DataFrame into Redshift without S3

You have a DataFrame. You need it in Amazon Redshift. You cannot use S3.

Maybe nobody will give you a bucket. Maybe your VPC has never heard of S3 and has no plans to. Maybe the ticket asking for either has been "in triage" since March. Whatever the reason, COPY — the answer in every tutorial, every AWS doc, and every Stack Overflow reply you have read in the last twenty minutes — is not available to you.

Frameshift is the fire escape. It uses the Redshift connection you already have and asks for nothing else.

pip install "frameshift[psycopg2]"
from frameshift import FrameShift

with FrameShift(host="...", database="...", user="...", password="...") as fs:
    fs.load(df, "my_table")     # creates the table if it isn't there

That's it. That's the library.

This is a bad idea and you should probably use COPY

Let's get this out of the way, because you will hear it from someone eventually and it may as well be from the README:

Frameshift is roughly 10-20x slower than COPY. There. Said it. Right under the install instructions, where you can't miss it.

Loading Redshift with INSERT is the wrong way to load Redshift. AWS says so. Every consultant says so. Your colleague who has Opinions about data warehouses will absolutely say so. They are all correct. Redshift wants to slurp files out of S3 in parallel across every slice it owns. Frameshift politely hands the leader node one statement at a time and waits.

(We tried being clever. Sixteen parallel threads, mimicking Redshift's own MD5 hash distribution. The leader node serializes everything anyway. It was a lovely afternoon and it accomplished nothing.)

Do not build a production pipeline on this. If you catch yourself scheduling it nightly, that is not a Frameshift feature, that is a cry for help. Go file the S3 ticket. This library is for getting out of a bind: the one-off load, the lookup table, the test fixture, the notebook that has to be done before the meeting, the locked-down environment where the "right" answer is somebody else's department.

It's quick and dirty, and it intends to stay that way. The one promise it makes is that quick and dirty will still be correct: your quotes are escaped, your types are sane, your statements fit inside Redshift's limits, and if something explodes it rolls back instead of leaving you with a table that's 60% loaded and 100% your problem.

If you can reach S3, close this tab and use COPY. Sincerely. We'll be here if it doesn't work out.

Is this you?

If any of these is your afternoon, you're in the right place:

  • "I don't have S3 write access." You can query Redshift all day, but a bucket? That's a different team, a different ticket, and a different quarter.
  • "My VPC can't reach S3." No endpoint, no NAT gateway, no dice. COPY can't fetch a file it can't get to.
  • "We're air-gapped." Or in GovCloud, or behind a proxy that treats s3.amazonaws.com as a personal insult.
  • "Nobody will give me the S3 credentials." They exist. Somewhere. Someone has them. It is not you.
  • "I'm stuck on the Redshift Data API." With its 100 KB statement ceiling. (FrameShiftConfig.for_data_api() handles the arithmetic.)
  • "It's a Lambda / notebook / CI job." Staging a file and cleaning it up is more work than the actual load.
  • "It's 200 rows, once." Standing up an S3 pipeline for 200 rows is like renting a freight elevator to move a houseplant.

If none of those are you: COPY, or awswrangler, which wraps it nicely. No hard feelings.

Where the wheels come off

Orders of magnitude, not benchmarks. Your columns, your cluster, and your network all matter more than the row count.

Rows Verdict
Up to ~10k Fine. You won't even notice.
~10k–100k Works. You'll notice. Acceptable if S3 truly isn't an option.
~100k–1M Now you're just hurting yourself.
Over ~1M Go get S3 access. We both know it's time.

estimate_load() will tell you which row you're on before you commit to finding out the hard way.

Basic usage

import pandas as pd
from frameshift import FrameShift

df = pd.DataFrame({
    "id": [1, 2, 3],
    "name": ["Alice", "Bob", "Charlie"],
})

with FrameShift(
    host="cluster.region.redshift.amazonaws.com",
    database="mydb",
    user="admin",
    password="secret",
) as fs:
    result = fs.load(df, "users")
    print(result.summary())

The table is created if it does not exist, with types inferred from the DataFrame.

Bring your own connection

Frameshift does not require a driver, and in the environments it is written for the driver is often not your choice. Pass whatever you already have:

fs = FrameShift(connection=my_existing_connection)

Any DB-API connection works. Frameshift will not close a connection it did not open.

Or install one:

pip install "frameshift[psycopg2]"             # psycopg2
pip install "frameshift[redshift-connector]"   # Amazon's driver
pip install "frameshift[sqlalchemy]"           # SQLAlchemy

Loading

fs.load(df, "users", if_exists="append")    # default
fs.load(df, "users", if_exists="replace")   # DROP, then CREATE
fs.load(df, "users", if_exists="fail")      # raise if the table exists

That is the whole API for most uses. Types are inferred from the DataFrame and the table is created if it does not exist.

If you happen to know your table wants a sort or distribution key, they are one keyword each — but if you are here to get unblocked, skip them:

fs.load(df, "events", sortkey="created_at", distkey="user_id")

validate_unique=True is the one extra worth knowing about, because Redshift does not enforce uniqueness constraints — a duplicate you do not catch here is a duplicate you find in a report next quarter:

fs.load(df, "events", unique_key="event_id", validate_unique=True)

Configuration

from frameshift import FrameShift, FrameShiftConfig

config = FrameShiftConfig(
    batch_size=1000,        # rows per INSERT (a hard cap)
    commit_every=10,        # commit every N chunks; 0 = one transaction
    on_error="abort",       # "abort" | "skip" | "log"
    dry_run=False,          # render SQL without executing it
)

fs = FrameShift(host="...", database="...", user="...", password="...", config=config)

Presets: FrameShiftConfig.for_data_api(), for_small_datasets(), for_large_datasets().

When things go wrong

By default (on_error="abort") the load is one transaction: all of it lands or none of it does. A failure rolls back and leaves your connection usable, which sounds like the bare minimum until you meet the libraries that don't.

With on_error="skip" or "log", each chunk gets its own savepoint, so one bad chunk rolls back alone and the rest carries on. Check result.success and result.errors afterwards — a partial load says success=False and tells you exactly which rows didn't make it, rather than letting you find out from a dashboard in three weeks.

Seeing the SQL before you run it

for sql in fs.generate_sql(df, "users"):
    print(sql)

Or set dry_run=True, which makes load() render everything without touching the database. Good for reviewing what you're about to do, for emailing the SQL to the DBA who has the permissions you don't, and for satisfying yourself that this library isn't doing anything clever behind your back. (It isn't. That's the point.)

One caveat: generated SQL has your data inlined in it. Don't paste it into a public ticket.

How much will this hurt?

estimates = fs.estimate_load(df, "users")

Tells you the row count, how many INSERT statements it will take, and warns you when you have wandered past what this approach is good for. Worth thirty seconds before a load you are unsure about.

Optional: schema inspection and DISTKEY analysis

Skip this if you are here to get unblocked — it exists for the case where the table you are creating in a hurry is one you will keep.

schema = fs.infer_schema(df, "users")
print(schema.to_create_table_sql())     # see the DDL before it runs

recommendations = fs.get_recommendations(df, "users")

Choosing a DISTKEY badly is the classic Redshift mistake: the data lands unevenly across slices and every query afterwards pays for it. Frameshift simulates Redshift's hash distribution locally, so you can see the skew without creating anything:

analysis = fs.analyze_distribution(df, "user_id")
print(analysis.summary())

comparison = fs.compare_distkeys(df, ["user_id", "account_id", "region"])

And if you do not know what your key is:

validation = fs.validate_unique_key(df, ["order_id", "line_number"])
candidates = fs.find_natural_keys(df, max_columns=3)

Type mapping

pandas Redshift
int8, int16 SMALLINT
int32 INTEGER
int64 BIGINT
float32 REAL
float64 DOUBLE PRECISION
bool BOOLEAN
object (text) VARCHAR(n), sized from the data
object (dict/list) SUPER
object (bytes) VARBYTE
datetime64[ns] TIMESTAMP
datetime64[ns, tz] TIMESTAMPTZ

VARCHAR lengths are measured in UTF-8 bytes, because that is what Redshift actually limits — not characters. One emoji is four bytes. Frameshift counts correctly so your column doesn't reject the very data it was sized for.

Columns of 1/0 integers are not inferred as BOOLEAN, no matter how much they look like one. They're usually counts, ids, or an enum somebody was clever about, and guessing wrong here is not reversible. Pass an explicit column_spec if you really do mean boolean.

Override inference per column:

from frameshift import ColumnSpec, RedshiftType

fs.load(df, "users", column_specs=[
    ColumnSpec(name="id", redshift_type=RedshiftType.BIGINT, nullable=False),
    ColumnSpec(name="notes", redshift_type=RedshiftType.VARCHAR, length=4096),
])

Security

Frameshift generates SQL as text, so escaping is its responsibility. Values are escaped to match Redshift's own QUOTE_LITERAL; identifiers are validated against an allowlist rather than merely quoted. The guarantees, the limits, and how to report a problem are in SECURITY.md.

If you are passing table names from untrusted input, read that file first.

Alternatives

Tool Use it when
Redshift COPY You can reach S3. This is the right answer and you know it.
awswrangler You can reach S3 and would like a nice Python wrapper around the right answer.
pandas.to_sql You want something generic and have no feelings about DISTKEY or statement sizing.
Frameshift S3 is not happening, and you'd still like the table to come out sensible.

Questions you are probably typing into Google right now

How do I load a DataFrame into Redshift without S3? This library, and you have found it, so that's one problem solved. COPY needs a file staged in S3; Frameshift generates INSERT statements instead and needs nothing but a database connection. It is slower. See the table above for how much slower and when to stop.

What if my IAM role has no S3 write access? Then COPY is out and you're in the right place. Frameshift never touches S3 and wants no IAM permissions at all — just the Redshift privileges to CREATE TABLE and INSERT.

Can I use this from a VPC with no S3 endpoint? Yes. This is one of the reasons it exists. COPY can't fetch a file it has no route to. Frameshift only talks to Redshift, which you can evidently already reach, or you'd have a much larger problem than this README.

Why is pandas.to_sql so slow against Redshift? Because it sends one INSERT per row, and every single one is a round trip the leader node has to think about. Frameshift packs as many rows as will fit into each statement, up to Redshift's 16 MB limit — orders of magnitude fewer round trips. Still not COPY. Nothing is COPY except COPY.

Does this work with the Redshift Data API? Yes. FrameShiftConfig.for_data_api() sizes everything under its 100 KB statement ceiling so you don't have to think about it.

Is it safe against SQL injection? Values are escaped to match Redshift's own QUOTE_LITERAL, and identifiers are validated against an allowlist rather than just wrapped in quotes and hoped over. SECURITY.md has the guarantees and — more importantly — their limits. Read it before you pass a table name that came from a user.

Can I use this in production? You can. Sharp objects are also legal. See the second section of this README, which was written specifically for the person asking this.

Requirements

Python 3.10+, pandas 1.5+. A driver is optional -- see above.

Contributing

See CONTRIBUTING.md. If you are touching SQL generation, tests/test_injection.py is the file that matters; read its header first.

License

MIT

Metadata

Release files for frameshift 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for frameshift 1.1.0
File Size Uploaded
frameshift-1.1.0.tar.gz 60.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for frameshift 1.1.0
File Interpreter ABI Platform
frameshift-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 104.9 kB

Release files / frameshift-1.1.0.tar.gz

Download URL frameshift-1.1.0.tar.gz
Size 60.2 kB
Tags Source
SHA-256 checksum
How to use checksums
b59e7ed7226780ea5a0bbd492d4e3aea47c7750eeeb3357abca88521fb0878d1
BLAKE2b-256 checksum
How to use checksums
c5bab117941b61ee61689c6f423ed6589bf209430dba5d38bad8609520313fc0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.13

Release files / frameshift-1.1.0-py3-none-any.whl

Download URL frameshift-1.1.0-py3-none-any.whl
Size 44.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4bbac6046b49bc99b390ca9715f0744adfd75a7fbe9093d4175c09636fd5f981
BLAKE2b-256 checksum
How to use checksums
ddf728acb4a07c6decdf91fd5c661ca2c28493cc4a883c042c81ca263e310893
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.13

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page