From 2b6be8c7341fab4bc554b29e0f91f350c3c02f41 Mon Sep 17 00:00:00 2001 From: Max Rekunchak Date: Sun, 12 Jul 2026 02:06:36 +0300 Subject: [PATCH 1/2] BUG: Register SQLite adapters only once per process (GH#64337) Every call to DataFrame.to_sql() with a SQLite connection created a new SQLiteTable instance whose __init__ unconditionally called sqlite3.register_adapter() and sqlite3.register_converter(). These are global mutations, overwriting any custom adapters/converters the user may have registered. Fix: use a module-level _SQLITE_ADAPTERS_REGISTERED flag so adapters and converters are registered only once per process. After the first to_sql() call, subsequent calls are no-ops. This preserves pandas' required adapters (needed because Python 3.12+ removed default datetime adapters) while not stomping on user handlers. --- pandas/io/sql.py | 17 +++++++++++++++-- pandas/tests/io/test_sql.py | 28 +++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/pandas/io/sql.py b/pandas/io/sql.py index 5825e96666e44..074761b88f285 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -2544,6 +2544,11 @@ def _quote_identifier(name: object) -> str: return '"' + uname.replace('"', '""') + '"' +# Guard to avoid overwriting user-registered SQLite adapters/converters +# on every to_sql() call. See GH#64337. +_SQLITE_ADAPTERS_REGISTERED: bool = False + + class SQLiteTable(SQLTable): """ Patch the SQLTable for fallback support. @@ -2556,10 +2561,16 @@ def __init__(self, *args, **kwargs) -> None: self._register_date_adapters() def _register_date_adapters(self) -> None: - # GH 8341 - # register an adapter callable for datetime.time object + # GH 8341 — register adapter callable for datetime.time object + # GH 61092 — register adapters for date/datetime (Python 3.12+) + # GH 64337 — only register once per process to avoid overwriting + # user-registered adapters on subsequent to_sql() calls. import sqlite3 + global _SQLITE_ADAPTERS_REGISTERED + if _SQLITE_ADAPTERS_REGISTERED: + return + # this will transform time(12,34,56,789) into '12:34:56.000789' # (this is what sqlalchemy does) def _adapt_time(t) -> str: @@ -2584,6 +2595,8 @@ def _adapt_time(t) -> str: sqlite3.register_converter("date", convert_date) sqlite3.register_converter("timestamp", convert_timestamp) + _SQLITE_ADAPTERS_REGISTERED = True + def sql_schema(self) -> str: return str(";\n".join(self.table)) diff --git a/pandas/tests/io/test_sql.py b/pandas/tests/io/test_sql.py index 8d3817eb454a0..96af958f664a2 100644 --- a/pandas/tests/io/test_sql.py +++ b/pandas/tests/io/test_sql.py @@ -4414,6 +4414,32 @@ def test_xsqlite_if_exists(sqlite_buildin): (2, "B"), (3, "C"), (4, "D"), - (5, "E"), + (5, "E"), ] drop_table(table_name, sqlite_buildin) + + +def test_to_sql_preserves_user_sqlite_adapters(sqlite_buildin): + """ + GH#64337 — DataFrame.to_sql() should not overwrite user-registered + SQLite adapters on subsequent calls. + """ + import sqlite3 + + # First to_sql call — pandas registers its adapters + df = DataFrame({"t": [time(12, 30)]}) + df.to_sql("test_adapter_1", sqlite_buildin, if_exists="replace", index=False) + + # User registers a custom adapter for time + def custom_time_adapter(t): + return f"CUSTOM:{t.hour:02d}:{t.minute:02d}:{t.second:02d}" + + sqlite3.register_adapter(time, custom_time_adapter) + + # Second to_sql call — must NOT overwrite the user's adapter + df2 = DataFrame({"t": [time(14, 0)]}) + df2.to_sql("test_adapter_2", sqlite_buildin, if_exists="replace", index=False) + + # Verify user's adapter is still registered + registered = sqlite3.adapters.get((time, sqlite3.PrepareProtocol)) + assert registered is custom_time_adapter From 5d3ae4152a5d2b6bc72f48d03c1dad9c5759b602 Mon Sep 17 00:00:00 2001 From: Max Rekunchak Date: Sun, 12 Jul 2026 02:40:07 +0300 Subject: [PATCH 2/2] pre-commit: ruff format --- pandas/tests/io/test_sql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/tests/io/test_sql.py b/pandas/tests/io/test_sql.py index 96af958f664a2..c73fed4085625 100644 --- a/pandas/tests/io/test_sql.py +++ b/pandas/tests/io/test_sql.py @@ -4414,7 +4414,7 @@ def test_xsqlite_if_exists(sqlite_buildin): (2, "B"), (3, "C"), (4, "D"), - (5, "E"), + (5, "E"), ] drop_table(table_name, sqlite_buildin)