Scripting good practices in Python
And a lot of it works in other languages too
Summary
You can make your scripts just a little nicer with some tweaks:
Use inline dependencies if you need any.
Acquire and store secrets in a way that won’t leak them.
Make a clean split between print and log.
Document and prefix your env vars.
Support piping.
Exit cleanly.
Load configuration in the proper order.
When it’s easy to write code, people do
It’s been easier than ever to create a Python script. We have uv, inline deps, tons of fantastique 3rd party libs for easy argument parsing/env var reading/configuration loading, and of course we can ask an AI to generate it.
When something becomes easy to do, a lot of non-experts start to do it. It’s good, but it also means they don’t know the industry standard. The AI can do a lot, but you need to ask it to do so.
So today we are going to go through a list of things you can do to make your scripts nicer.
Inline dependencies
Claude and ChatGPT are fully aware this exists, but almost never do this.
We now have a specification to list your script dependencies directly in a comment at the top of the file. If you script requires requests and keyring, you can do:
# /// script
# dependencies = [
# "requests",
# "keyring",
# ]
# ///
print('Test Cedric')pip added support for this recently, and you can therefore install all dependencies of this script with --requirements-from-script. I would still advise doing that in a venv.
Of course, it’s even better if you use uv, since you can then just run the script transparently with:
uv run your_script.pyAnd uv will automatically create a temporary venv, install all the packages, and run the script in one operation, very quickly.
Dealing with secrets
Sometimes your scripts will require you to get some token or password that should stay safe. In that case, it is better if you don’t hardcode it or even pass it as a parameter (so it doesn’t show in the shell history).
What can you do then?
Well, you should read it from an environment variable, and if this is not provided, prompt for it with getpass.
import os
import getpass
def get_api_token():
# A good practice is to prefix env vars you create by the name of your script
# to limit name colision.
token = os.getenv("YOUR_SCRIPT_API_TOKEN")
if not token:
print('YOUR_SCRIPT_API_TOKEN not found in env vars, please provide it manually.')
token = getpass.getpass("API token: ")
return token
token = get_api_token()Now, since it’s annoying to make sure the token is available every time, you can store it in the user's OS keyring, where it will be safely encrypted and stored. This is usually automatically unlocked at login, and you can use a 3rd party lib from PyPI to read from it. The example becomes:
# /// script
# dependencies = [
# "requests",
# "keyring",
# ]
# ///
import os
import getpass
import keyring
SCRIPT_NAME = "your_script"
USERNAME = "your_username"
def get_api_token():
# Environment variable takes precedence so that the user can always override it
# if needed (e.g: for tests)
token = os.getenv("YOUR_SCRIPT_API_TOKEN")
if token:
return token
# Try the OS keyring, in case the token has been saved in there by a previous run
token = keyring.get_password(SCRIPT_NAME, USERNAME)
if token:
return token
# Fallback to asking the user
print('API_TOKEN not found in env vars, please provide it manually.')
token = getpass.getpass("API token: ")
# 4. Save it for next time
# ... put whatever code that checks that the token works to not save a bad one
# then save it in the keyring
keyring.set_password(SCRIPT_NAME, USERNAME, token)
return token
token = get_api_token()
If the secret is very big, like an entire file, you can even encrypt it with cryptography.fernet, and just save the encryption key in the keyring.
This is, of course, not bulletproof, but it’s better than 99% of the scripts you will see out there.
To print or to log?
At first, you should just print. A simple script should stay simple, no need to make it super complicated for no reason.
But if your script becomes a bit more advanced, then you should split it into two types of feedback:
Feedback for the user of the script, the people running it (it can be you, later). Use
print().Feedback for the dev of the script, the people writing and debugging it (it could be you or your user trying to figure out what the hell is going on). For that, use logging.
import os
import logging
from pathlib import Path
# Allow the user to choose the granularity of the logs
LOG_LEVEL = getattr(logging, os.getenv("YOUR_SCRIPT_LOG_LEVEL", "").upper(), 9999)
logging.basicConfig(
level=LOG_LEVEL,
# Simple log format for scripts
format="%(asctime)s [%(levelname)s] %(message)s",
)
# For a simple script, not need for getLogger, basicConfig configures the root handler
logging.error("This is for debugging")
print("This is to talk to the user")You’ll note that by default we store a log level of 9999. That’s because we disable the logs, and just let the user messages. The logs are activated ONLY if someone requests them, otherwise, they are noise:
❯ python your_script.py
This is to talk to the user
❯ LOG_LEVEL=INFO python your_script.py
2026-06-21 17:34:48,564 [ERROR] This is for debugging
This is to talk to the userFor a simple script, we don’t store the logs in a file. Indeed, a user who can set an env var has the knowledge to store all logs in a file by doing a shell redirection, and this saves you from the complexity of deciding WHERE to put said file yourself.
Document your env vars
Env vars cannot be magically discovered; you need to advertise this somehow. I recommend documenting all env vars as parameters on --help. You can actually add free text to any argparse parser, even if you don’t have any parameters!
import argparse
parser = argparse.ArgumentParser()
parser = argparse.ArgumentParser(
# Preserve line breaks
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Environment variables:
LOG_LEVEL Logging level (default: disabled)
API_TOKEN Token to use to authenticate to xxxx (prompt if missing)
"""
)
args = parser.parse_args()This is now documented:
❯ python your_script.py -h
usage: your_script.py [-h]
options:
-h, --help show this help message and exit
Environment variables:
LOG_LEVEL Logging level (default: disabled)
API_TOKEN Token to use to authenticate to xxxx (prompt if missing)stderr or stdout?
Because stderr stands for “standard error”, we tend to think that it’s made for outputting errors. But it’s a bit more complicated than this. E.G: all logs go to stderr by default, even INFO.
This is because a script's output is often redirected, and the most common redirection is to split the data stream of stdout from the one on stderr, to send them in two different places (E.G: a pipe and a file).
A good rule of thumb is that operations results go to stdout, errors and logs to stderr.
How do you write to stderr?
import sys
print("This goes to stderr", file=sys.stderr)Since this is a tag annoying to type every time, you can make a partial for it:
import sys
from functools import partial
print_err = partial(print, file=sys.stderr)
print_err("This goes to stderr")Since it’s nice to be able to see errors for the user from miles away, you can color them in red. That can be done manually:
import sys
RED = "\033[31m"
RESET = "\033[0m"
def eprint(*args, **kwargs):
print(RED, *args, RESET, file=sys.stderr, **kwargs)But better use a lib like rich that will handle edge case like old windows setup, no tty, pipes, etc.
# /// script
# dependencies = [
# "rich",
# ]
# ///
import sys
from rich.console import Console
print_error = Console(file=sys.stderr).print
console.print("[red]Error happened[/red]")Whether or not to use a library depends on how feature-rich you want that to be for the time you spend, and how annoying it is to install dependencies for the target users.
Take it in
It’s convenient when you use a shell to be able to pipe data to a script, so if your script is supposed to accept a big input (like a file, for example), give the option.
This must be done carefully, though, as you don’t want to read stdin if it’s coming manually from the user, so we must guard it:
import sys
def read_piped_stdin():
# isatty() is True for an interactive terminal => nothing was piped
# We check for None for windows .pyw files
if sys.stdin is None or sys.stdin.isatty():
return None
return sys.stdin.read()
data = read_piped_stdin()
if not data:
# read() returns "" if nothing is piped
print("no input piped")
else:
print(f"got {len(data)} chars from stdin")This will let the user do:
❯ cat /etc/fstab | python your_script.py
got 640 chars from stdinMake a good exit
All programs end their execution by returning a code, which is 0 when it went well, and between 1 and 255 if it ended with an error.
This is useful for automation since other shell commands use those codes to make decisions, and a well-behaved script should therefore choose to return codes on exit.
So you should at least use sys.exit() when you stop the script because something is going wrong:
import sys
if something_is_off_I_can_feel_it():
# This will exit the script and return code 1
sys.exit("Let's split up, we will cover more ground")You may call sys.exit() with a number instead to choose precisely the code, but then you should document said code in your --help to state what they are for. This is probably more trouble than most people want.
I also advocate for shipping programs that don’t print a stack trace on crash. It’s much better to hide it unless the information is requested (E.G: for debug), because they often don’t know what to do with the info, at best.
Instead, set up logging, swallow the exception, and then, if logging is activated, you will see the stack trace:
import os
import sys
import logging
LOG_LEVEL = getattr(logging, os.getenv("YOUR_SCRIPT_LOG_LEVEL", "").upper(), 9999)
logging.basicConfig(
level=LOG_LEVEL,
# Simple log format for scripts
format="%(asctime)s [%(levelname)s] %(message)s",
)
def on_crash(exctype, value, traceback):
if logging.getLogger().isEnabledFor(logging.ERROR):
logging.error("Uncaught exception", exc_info=(exctype, value, tb))
else:
print(
"An unexpected error occured. Retry or contact the author.\n"
"Set YOUR_SCRIPT_LOG_LEVEL=ERROR to get debug information on the next run."
)
sys.excepthook = on_crash
1 / 0 # I should make a macro for thisThe result:
❯ python script.py
An unexpected error occured. Retry or contact the author.
Set YOUR_SCRIPT_LOG_LEVEL=ERROR to get debug information on the next run.
❯ YOUR_SCRIPT_LOG_LEVEL=ERROR python script.py
2026-06-21 18:27:13,300 [ERROR] Uncaught exception
Traceback (most recent call last):
File "/tmp/script.py", line 26, in <module>
1 / 0 # I should make a macro for this
~~^~~
ZeroDivisionError: division by zeroMake it a priority
Configuration should almost always be defined by order of priority from (highest to lowest):
Manual user input when prompted
User passed arguments from the command line
Env variables
Local configuration file
User configuration file
System configuration file
Default value
This means that if you have a variable at the system level, a user configuration file can override it, which can be overrided by a configuration file in the local directory, which should be also easy to override with a any env var that exist in the current session, which will be overrided by any argument passed at this run, which, finally, will be overriden by user choices right now. And if nothing is provided, you use the default value.
You don’t NEED to have that many configuration layers. You can have 1, 2, or none. It’s fine.
But if you have several of them, they will conflict, and you MUST have a clearly defined order for resolving that, and this is standard.
E.G, if I have a retry variable with a default value, that can be set by env var and CLI params, I should do:
import os
import argparse
DEFAULT_RETRY = 3
parser = argparse.ArgumentParser()
# IRL, we should validate that
retry_from_env = int(os.getenv("YOUR_SCRIPT_RETRY", DEFAULT_RETRY))
parser.add_argument("--retry", type=int, default=retry_from_env)
args = parser.parse_args()
print("RETRY =", args.retry)Which effectively gives you:
❯ python script.py
RETRY = 3
❯ YOUR_SCRIPT_RETRY=5 python script.py
RETRY = 5
❯ YOUR_SCRIPT_RETRY=5 python script.py --retry 10
RETRY = 10It’s super annoying to do all that
Yes, you should probably not. Use each improvement only if you need it. Again, a simple script should stay simple.
Plus if you feel really lazy like me, you can let pydantic a lot of it.
And lo and behold, we have a great article to explain how to do that:

Another tip: use click (https://click.palletsprojects.com/en/stable/)
Thank me later 🙃