maxsql
MySQL in one line per operation.
maxsql is a small helper around mysql-connector-python. You save your connection once, and after that every operation (create, insert, select, update, delete, export, alter, import) is a single call. Connections are pooled, failed connections are retried with backoff, and errors are printed instead of crashing your script.
Table of contents
- Features
- Installation
- Quick start
- Connections
- Query parameters
- Operations
- Messages and the display flag
- Settings
- Retry and pooling behaviour
- Where connections are saved
- Full example script
- Troubleshooting
- Security notes
Features
- Save a connection once, use it from every script.
- One line per operation:
create,insert,select,update,delete,export,alter,import_. - Connection pooling, so each call does not open a fresh connection.
- Automatic retry with exponential backoff for transient connection errors.
- Several saved connections, with a default and a per-script override.
- Works with several databases in the same script through
Databaseobjects. - numpy and pandas values (NaN, NaT,
pd.NA, numpy integers, Timestamps) are converted to MySQL-safe values for you. import_builds a table straight from a pandas DataFrame with type detection.alteradds missing columns by looking at sample values.- Errors and warnings always print and the call returns
NoneorFalse, so a failure never crashes your script.
Installation
pip install maxsql
This also installs mysql-connector-python and pandas for you.
Quick start
import maxsql
# Once per connection. Asks for host, user, password and database.
maxsql.configure()
# Create a table
maxsql.create("""
CREATE TABLE IF NOT EXISTS customers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
city VARCHAR(80),
credit_limit INT
)
""")
# Insert
new_id = maxsql.insert(
"INSERT INTO customers (name, city, credit_limit) VALUES (%s, %s, %s)",
("Acme Ltd", "Lagos", 5000),
)
# Select
rows = maxsql.select("SELECT * FROM customers WHERE credit_limit > %s", (1000,))
print(rows)
# Update
maxsql.update("UPDATE customers SET credit_limit = %s WHERE id = %s", (7500, new_id))
# Delete
maxsql.delete("DELETE FROM customers WHERE id = %s", (new_id,))
After the first run you can remove the configure() line. The connection is saved on disk and picked up automatically.
Connections
A connection is saved under a name. By default the name is the database name, so a database called shop is saved as shop.
If you only ever save one connection, you never need default() or use(). They matter when you have several.
configure
Saves a connection. Run it once per connection.
maxsql.configure(
host=None, # asks if not given; press Enter to accept 127.0.0.1
user=None, # asks if not given
password=None, # asks if not given (input is hidden)
database=None, # asks if not given
port=3306,
local=False, # True skips the host question and assumes 127.0.0.1
name=None, # connection name, defaults to the database name
display=True, # show the "Saved" confirmation
default=False, # True makes this the default connection
)
Returns True when saved and False when not.
Interactive, asks for everything:
maxsql.configure()
Local server, skip the host question:
maxsql.configure(local=True)
Fully non-interactive:
maxsql.configure(
host="127.0.0.1",
user="root",
password="secret",
database="shop",
)
Same database name on two different servers. Give each one its own name:
maxsql.configure(name="shop_local")
maxsql.configure(name="shop_prod")
If you try to save a name that already exists for a different server, maxsql refuses and prints a message showing both entries and how to keep both. Saving the same name for the same server replaces the saved details, which is how you update a password:
maxsql.configure(user="root", password="new_password", database="shop", local=True)
Make a connection the default while saving it:
maxsql.configure(name="shop_prod", default=True)
The first connection you ever save becomes the default automatically. After configure() runs, that connection is also the active one for the current script.
default
Sets, checks or turns off your default connection. The default is saved on disk and used by every script that does not call use().
maxsql.default("shop") # make shop the default, returns True/False
maxsql.default() # print and return the current default (or None)
maxsql.default(off=True) # no default; with several saved, use() is then required
maxsql.default("shop", display=False) # no confirmation message
Passing both a name and off=True raises a ValueError.
use
Chooses the connection for the current script only. It overrides the default without changing it.
maxsql.use("shop_prod")
rows = maxsql.select("SELECT COUNT(*) FROM customers")
Returns True when the connection exists and False (with a message) when it does not.
If several connections are saved, no default is set and you do not call use(), maxsql prints which connections exist and how to choose one.
configs
Lists every available connection. Passwords are hidden. The active one is marked, the default is labelled, and in-memory connections are labelled "this run only".
maxsql.configs()
Example output:
▶ shop root@127.0.0.1:3306/shop password: ******** (default)
shop_prod app@10.0.0.5:3306/shop password: ********
forget
Removes a saved connection. If it was the default, the default is cleared.
maxsql.forget("shop_prod")
maxsql.forget("shop_prod", display=False) # silent
Returns True when removed and False when nothing with that name exists.
Working with several databases at once
use() switches the whole script. To talk to two databases at the same time, create a Database for each one. A Database("name") is tied to that connection no matter what use() says.
import maxsql
shop = maxsql.Database("shop")
accounting = maxsql.Database("accounting")
customers = shop.select("SELECT * FROM customers")
accounting.insert(
"INSERT INTO invoices (customer, amount) VALUES (%s, %s)",
("Acme Ltd", 249.95),
)
Every module-level function has a method with the same name and arguments on Database:
create, insert, select, update, delete, export, alter, import_, cursor.
Database() with no name follows whatever use() chose, which is exactly what maxsql.select(...) does.
Hardcoding credentials for one run
Pass the details to use() and nothing is saved to disk. This is handy for scripts, containers and CI.
maxsql.use("shop", host="127.0.0.1", user="root", password="secret")
Keyword details override any saved details with the same name, for this run only. host defaults to 127.0.0.1, port to 3306 and database to the connection name. A user and password are required.
Reading credentials from environment variables is the safest way to hardcode:
import os
import maxsql
maxsql.use(
"shop",
host=os.environ["DB_HOST"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
)
Query parameters
Every query function accepts parameters in four shapes:
# 1. No parameters
maxsql.select("SELECT * FROM customers")
# 2. A single value
maxsql.select("SELECT * FROM customers WHERE id = %s", 7)
# 3. A tuple or list, with %s placeholders
maxsql.select("SELECT * FROM customers WHERE credit_limit > %s AND city = %s", (1000, "Lagos"))
# 4. A dict, with %(name)s placeholders
maxsql.select(
"SELECT * FROM customers WHERE credit_limit > %(limit)s AND city = %(city)s",
{"limit": 1000, "city": "Lagos"},
)
Always pass values as parameters instead of building the query with f-strings. It prevents SQL injection and handles quoting for you.
Values are cleaned before they are sent: numpy and pandas scalars become plain Python values, and NaN, NaT, pd.NA and infinity become None (SQL NULL).
import numpy as np
import pandas as pd
maxsql.insert(
"INSERT INTO daily_sales (order_count, avg_order_value, report_date) VALUES (%s, %s, %s)",
(np.int64(38), np.float64("nan"), pd.Timestamp("2026-08-21")),
)
# the NaN is stored as NULL
Table and column names cannot be passed as parameters. Only alter and import_ validate names for you (letters, numbers and underscores, up to 64 characters). In your own raw queries, never insert user-supplied names directly.
Operations
create
Runs a statement such as CREATE TABLE and commits.
maxsql.create("""
CREATE TABLE IF NOT EXISTS orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer VARCHAR(80),
product VARCHAR(80),
ordered_at DATETIME,
quantity INT,
amount DECIMAL(10, 2)
)
""")
Returns True on success and False on failure. Pass display=False to hide the success message:
ok = maxsql.create("CREATE TABLE IF NOT EXISTS t (id INT)", display=False)
insert
Inserts a row and returns the new row id (None on error).
row_id = maxsql.insert(
"INSERT INTO orders (customer, product, ordered_at) VALUES (%s, %s, %s)",
("Acme Ltd", "Widget A", "2026-10-18 16:30:00"),
)
print(row_id)
Inserting in a loop:
new_orders = [
("Globex", "Widget B", "2026-10-19 12:30:00"),
("Initech", "Widget C", "2026-10-19 15:00:00"),
]
for customer, product, ordered_at in new_orders:
maxsql.insert(
"INSERT INTO orders (customer, product, ordered_at) VALUES (%s, %s, %s)",
(customer, product, ordered_at),
)
insert is silent by default because it usually runs in loops. Turn the confirmation on with display=True:
maxsql.insert("INSERT INTO t (id) VALUES (%s)", (1,), display=True)
For thousands of rows, use import_ with a DataFrame instead of a loop.
select
Returns a list of rows (tuples). Returns None on error.
rows = maxsql.select("SELECT customer, product FROM orders WHERE customer = %s", ("Acme Ltd",))
for customer, product in rows:
print(customer, "ordered", product)
With column names. Pass headers=True to get (rows, column_names):
rows, columns = maxsql.select("SELECT * FROM orders", headers=True)
print(columns) # ['id', 'customer', 'product', 'ordered_at', 'quantity', 'amount']
Straight into a DataFrame:
import pandas as pd
rows, columns = maxsql.select("SELECT * FROM orders", headers=True)
df = pd.DataFrame(rows, columns=columns)
Single value:
total = maxsql.select("SELECT COUNT(*) FROM orders")[0][0]
Check for None before indexing if the query might fail.
update
Returns the number of rows affected (None on error).
changed = maxsql.update(
"UPDATE orders SET quantity = %s, amount = %s WHERE id = %s",
(5, 249.95, 1),
)
print(changed, "row(s) updated")
With a confirmation message:
maxsql.update("UPDATE orders SET quantity = 0", (), display=True)
delete
Returns the number of rows deleted (None on error).
removed = maxsql.delete("DELETE FROM orders WHERE id = %s", (1,))
print(removed, "row(s) deleted")
export
Runs one or more queries and saves each result as a CSV file. Needs pandas.
files = maxsql.export(
[
["Orders", "SELECT * FROM orders"],
["AcmeOrders", "SELECT * FROM orders WHERE customer = 'Acme Ltd'"],
],
output_dir="TABLES",
)
print(files) # ['TABLES/Orders.csv', 'TABLES/AcmeOrders.csv']
queriesis a list of[name, query]pairs.output_dirdefaults to"TABLES"and is created if it does not exist.- Names must be plain file names with no folders. A name like
"../x"is skipped with a warning. - A query that fails is skipped and the others still run.
- Returns the list of files written.
Silent version:
maxsql.export([["Customers", "SELECT * FROM customers"]], output_dir="out", display=False)
alter
Adds any columns that do not exist yet, picking each type from a sample value. It never changes or removes existing columns.
added = maxsql.alter(
"orders",
{
"sales_rep": "Jane Doe", # VARCHAR(200)
"items": 12, # INT
"tax_rate": 7.5, # DOUBLE
"paid": False, # BOOLEAN
"invoice_ref": "INV-2026-0042", # VARCHAR(200), a plain string stays a string
"notes": None, # DOUBLE, because None defaults to a number
},
)
print(added) # list of columns that were added
Type inference from the sample value:
| Sample value | MySQL type |
|---|---|
bool |
BOOLEAN |
int up to 2,147,483,647 |
INT |
larger int |
BIGINT |
float |
DOUBLE |
datetime.datetime |
DATETIME |
datetime.date |
DATE |
str |
VARCHAR(200) |
None |
DOUBLE, or VARCHAR(200) if the column is in string_keys |
Making a None sample a text column:
maxsql.alter("orders", {"notes": None}, string_keys=("notes",))
Real dates:
import datetime
maxsql.alter("orders", {
"created_at": datetime.datetime.now(), # DATETIME
"invoice_day": datetime.date.today(), # DATE
})
Column name matching is case-insensitive, like MySQL. Table and column names are validated (letters, numbers, underscores, 64 characters max) and a bad name raises a ValueError. alter returns an empty list if the table does not exist.
import_
Creates a table from a pandas DataFrame (if it does not exist) and bulk-inserts every row. The name ends with an underscore because import is a Python keyword.
import pandas as pd
import maxsql
df = pd.DataFrame({
"product": ["Widget A", "Widget B", "Widget C"],
"units_sold": [240, 180, 95],
"revenue": [4800.0, 5400.0, 3325.5],
"last_sale": ["2026-10-04", "2026-10-05", "2026-10-05"],
})
inserted = maxsql.import_(df, "sales_summary")
print(inserted, "rows inserted")
What it does:
- Column types come from the DataFrame dtypes.
- Integers become
INT, orBIGINTwhen values are large (BIGINT UNSIGNEDbeyond the signed range). - Floats become
DOUBLE, so no precision is lost. - Booleans become
BOOLEAN, timedeltas becomeTIME. - Datetimes become
DATEwhen every time is midnight, otherwiseDATETIME. - Text becomes
VARCHAR(200), orTEXT,MEDIUMTEXTorLONGTEXTwhen the data is longer.
- Integers become
- Text columns that actually hold dates (
2026-10-04,04/10/2026, with or without a time) are detected and stored as realDATEorDATETIMEvalues. Plain numbers such as20261004or bit strings such as10000000are never mistaken for dates. NaN,NaTandpd.NAare stored asNULL. Infinite values cannot be stored in MySQL, so they are saved asNULLand a warning tells you how many.- Rows are sent in batches of 2,000 inside one transaction. If anything fails, the whole import is rolled back.
- Returns the number of rows inserted, or
0on failure.
Notes:
- If the table already exists,
CREATE TABLE IF NOT EXISTSleaves it alone and the rows are inserted into it. The DataFrame columns must match the existing table. - Column names must use only letters, numbers and underscores (64 characters max). Rename them first if needed:
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
- Passing something that is not a DataFrame raises a
TypeError. An empty DataFrame prints a warning and returns0. - Silent version:
maxsql.import_(df, "sales_summary", display=False).
Adding new columns to a table before importing a DataFrame with extra columns:
sample = df.iloc[0].to_dict()
maxsql.alter("sales_summary", sample)
maxsql.import_(df, "sales_summary")
cursor
Gives you the raw cursor and connection for anything the helpers do not cover.
cur, conn = maxsql.cursor()
if cur is not None:
try:
cur.execute("SELECT id, customer FROM orders WHERE id > %s", (10,))
for row in cur.fetchall():
print(row)
finally:
cur.close()
conn.close() # with pooling on, this hands the connection back to the pool
Always close the connection when you are done. With pooling, an unreleased connection stays checked out and can eventually leave no free connections. When something goes wrong, cursor() returns (None, None).
For several statements in one transaction:
cur, conn = maxsql.cursor()
try:
cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (50, 1))
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (50, 2))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()
Messages and the display flag
Errors and warnings always print and never raise (for example 🚫 Error: ... and ⚠️ ...). The call returns None or False so your script keeps running. Check the return value when it matters:
result = maxsql.select("SELECT * FROM missing_table")
if result is None:
print("query failed")
display controls only the success messages. It exists on create, insert, update, delete, alter, export, import_, configure, default and forget.
| Function | Default |
|---|---|
create, alter, export, import_, configure, default, forget |
True |
insert, update, delete |
False (they usually run in loops) |
select |
no display, it returns rows and has nothing to confirm |
Settings
These are module-level constants in maxsql/maxsql.py. Change them before your first query.
| Setting | Default | Meaning |
|---|---|---|
USE_POOLING |
True |
Reuse connections instead of opening one per call |
POOL_SIZE |
5 |
Connections per pool |
POOL_WAIT_TIMEOUT |
30 |
Seconds a call waits for a free pooled connection |
CONNECT_TIMEOUT |
10 |
Seconds to wait when opening a connection |
IMPORT_BATCH |
2000 |
Rows per statement in import_ |
CONNECT_RETRY_ATTEMPTS |
5 |
Attempts when opening a connection |
CONNECT_RETRY_BASE_DELAY |
2 |
First retry delay in seconds, doubling each time |
CONNECT_RETRY_MAX_DELAY |
30 |
Cap on the retry delay |
CONFIG_FILE |
~/.maxsql.json |
Where saved connections live |
DEFAULT_HOST |
127.0.0.1 |
Host used when none is given |
Because the code lives in the maxsql.maxsql submodule, set them there:
import maxsql
maxsql.maxsql.POOL_SIZE = 10
maxsql.maxsql.CONNECT_RETRY_ATTEMPTS = 3
maxsql.maxsql.USE_POOLING = False
Setting maxsql.POOL_SIZE = 10 on the package has no effect, because the functions read the value from the submodule. POOL_SIZE only applies to pools created after you change it, so set it before your first query.
Retry and pooling behaviour
Pooling. With USE_POOLING = True, each distinct set of connection details gets its own pool, shared by every Database object. Calls borrow a connection and hand it back. If every pooled connection is busy, the call waits up to POOL_WAIT_TIMEOUT seconds before giving up with a message.
Retry. When opening a connection fails, maxsql retries only errors that are likely to be temporary:
| Error code | Meaning |
|---|---|
| 2003 | Cannot connect to MySQL server |
| 2002 | Cannot connect to local MySQL server |
| 2013 | Lost connection during the query |
| 1040 | Too many connections |
| 1053 | Server shutdown in progress |
The wait doubles each time (2, 4, 8, 16 seconds) up to CONNECT_RETRY_MAX_DELAY. Errors that would never succeed on retry, such as a wrong password or an unknown database, fail immediately with a clear message. This helps when several processes start at once and hit the server together.
Where connections are saved
Saved connections live in one JSON file in your home folder: ~/.maxsql.json.
{
"default": "shop",
"profiles": {
"shop": {
"host": "127.0.0.1",
"user": "root",
"password": "secret",
"database": "shop",
"port": 3306
}
}
}
- The file is read from disk once and cached, so queries never touch it.
- It is written atomically (a temp file renamed over the real one), so a crash cannot leave a half-written file.
- A lock file keeps two processes from overwriting each other when both call
configure(). - On Linux and macOS the file is created with permissions
0600, so only you can read it. - An older
~/.mysql_helper.jsonfile is read once if the new file does not exist yet, and older formats are converted automatically.
Full example script
A complete script that uses most of the library:
import datetime
import pandas as pd
import maxsql
# One-time setup (safe to remove after the first run)
maxsql.configure(local=True)
# Table
maxsql.create("""
CREATE TABLE IF NOT EXISTS orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer VARCHAR(80),
product VARCHAR(80),
ordered_at DATETIME,
quantity INT,
amount DECIMAL(10, 2)
)
""", display=False)
# Insert
order_id = maxsql.insert(
"INSERT INTO orders (customer, product, ordered_at) VALUES (%s, %s, %s)",
("Acme Ltd", "Widget A", datetime.datetime(2026, 10, 18, 16, 30)),
)
# Add columns we forgot
maxsql.alter("orders", {"sales_rep": "Jane Doe", "tax_rate": 7.5})
# Update with the final quantity and amount
maxsql.update(
"UPDATE orders SET quantity = %s, amount = %s, sales_rep = %s WHERE id = %s",
(5, 249.95, "Jane Doe", order_id),
)
# Read into a DataFrame
rows, columns = maxsql.select("SELECT * FROM orders", headers=True)
df = pd.DataFrame(rows, columns=columns)
print(df)
# Bulk-load a DataFrame into a new table
summary = pd.DataFrame({"product": ["Widget A", "Widget B"], "revenue": [4800.0, 5400.0]})
maxsql.import_(summary, "sales_summary")
# Export results to CSV
maxsql.export([
["Orders", "SELECT * FROM orders"],
["TopProducts", "SELECT * FROM sales_summary ORDER BY revenue DESC"],
])
# Clean up
maxsql.delete("DELETE FROM orders WHERE id = %s", (order_id,))
Troubleshooting
"No saved connection." Run maxsql.configure() once, or call maxsql.use("name", host=..., user=..., password=...) in your script.
"Several connections are saved ..." You have more than one saved connection and no default. Choose one for the script with maxsql.use("name"), or set a default once with maxsql.default("name").
"No saved connection named 'x'." The name is wrong. Run maxsql.configs() to see what exists.
"Not saved: 'x' is already saved for a different server." The same database name is already saved for another host or port. Save it under its own name, for example maxsql.configure(name="x_prod"), or run maxsql.forget("x") first to replace it.
"Error connecting to MySQL: Access denied." The user or password is wrong. Run maxsql.configure() again with the same name to replace the saved details.
"Error connecting to MySQL: Unknown database." The database does not exist on that server. Create it first, or check the name.
"No free connection after waiting 30s." Every pooled connection is in use. You probably opened connections with cursor() and did not close them. Call conn.close() when finished, or raise maxsql.maxsql.POOL_SIZE.
ImportError: maxsql needs the MySQL driver. The MySQL driver is missing. This can happen if you copied the files by hand instead of using pip install maxsql. Install it manually:
pip install mysql-connector-python
ImportError: This function needs pandas. pandas is missing, and export and import_ need it. Install it manually:
pip install pandas
ValueError: Invalid table name ... alter and import_ only accept names made of letters, numbers and underscores, up to 64 characters.
TypeError: import_() needs a pandas DataFrame Convert your data first, for example pd.DataFrame(data).
Security notes
- Passwords are stored in plain text in
~/.maxsql.json. The file is private to your user on Linux and macOS, but on Windows the permission bits are mostly ignored, so protect your home folder accordingly. - For shared machines, servers and CI, prefer
use()with environment variables so no password is written to disk. - Never commit
~/.maxsql.jsonor scripts with hardcoded passwords to a public repository. - Use query parameters for all values. Only
alterandimport_validate table and column names, so never put user input into names in raw queries. - Give the MySQL user only the privileges your script needs.
Metadata
Release files for maxsql 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| maxsql-0.1.0.tar.gz | 32.5 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| maxsql-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 56.0 kB
Release files / maxsql-0.1.0.tar.gz
| Download URL | maxsql-0.1.0.tar.gz |
|---|---|
| Size | 32.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6ba78de53d17d2b373325588b68708f5112e76e2215ae5ed999052764271e465
|
|
BLAKE2b-256 checksum How to use checksums |
feb23bc187533771ffae18690d3faff2ba1bee20aa2aa0370e6134da050aabee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Release files / maxsql-0.1.0-py3-none-any.whl
| Download URL | maxsql-0.1.0-py3-none-any.whl |
|---|---|
| Size | 23.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ae2a4f622b5d9850a801914f3a02259773c76580d9676b3e49d357b4820ca050
|
|
BLAKE2b-256 checksum How to use checksums |
e26b27307234b821bf70b2e5b10a4bd2e3b1adbe8b5e637d6712d4798458c3a4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Security for maxsql 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.