The Python standard library ships with 200+ modules, and most developers only ever touch about 10 of them. Here are the 20 modules that save you from reinventing wheels, writing buggy workarounds, and reaching for pip when the answer is already sitting on your hard drive.
“Python comes with batteries included.”
Guido van Rossum, Python docs
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 20 minutes
The Python standard library is the set of modules that come bundled with Python itself. No pip install, no internet, no extra setup. “Batteries included” is the old motto, and knowing what is already in the box saves you from installing third-party packages for problems Python solved years ago.
Think of it like the toolbox that comes with a new house. You did not buy the wrench, the screwdriver, or the tape measure, but they are in the drawer when you need them. Most people forget they are there and go buy new ones. This post is the drawer tour: pathlib, collections, itertools, functools, json, re, datetime, and more. You will not memorize all 20 in one sitting. The goal is to build a mental index, so the next time you hit a problem your first thought is “does the standard library already do this?” Most of the time, it does.
Table of Contents
The Cheat Sheet: 20 Must-Know Modules
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram groups the essential standard library modules by what they do: file and operating system (OS) work, data handling, text and regular expressions (regex), system and runtime, and date and math. Each group holds 3 to 4 modules you will reach for again and again on real projects, so treat this as a practical toolkit, not a full catalog. The reference table right below gives you a one-line job description for every module, so you can scan for the one you need and jump straight to its section.
| Module | What It Does | When You Need It |
|---|---|---|
os | OS interface for env vars and process info | Reading environment variables, getting PID |
pathlib | Object-oriented file paths | Any file path manipulation (replaces os.path) |
sys | Python runtime config | Command-line args, exit codes, path manipulation |
json | JSON encode/decode | API responses, config files, data exchange |
csv | CSV read/write | Spreadsheet data, log files, data exports |
datetime | Dates, times, deltas | Timestamps, scheduling, age calculation |
re | Regular expressions | Pattern matching, text validation, parsing |
collections | Specialized containers | Counting, ordered dicts, default dicts, deques |
itertools | Iterator building blocks | Combinations, permutations, chaining, grouping |
functools | Higher-order function tools | Caching, partial functions, reduce |
math | Math functions | sqrt, ceil, floor, log, trig, factorial |
random | Random number generation | Shuffling, sampling, random choices |
logging | Logging framework | Application logging (replace print debugging) |
argparse | CLI argument parser | Building command-line tools |
subprocess | Run external commands | Shell commands, piping, process management |
shutil | High-level file operations | Copy, move, delete directories, disk usage |
sqlite3 | SQLite database interface | Local databases, prototyping, embedded data |
unittest | Testing framework | Unit tests (though pytest is more popular) |
typing | Type hint support | Type annotations for functions, classes |
dataclasses | Auto-generated data classes | Structured data without boilerplate |
File & OS: pathlib, os, shutil
A file path stored as a raw string is like an address scribbled as one long line on a napkin: you can read it, but nothing on it is labelled. A Path object is a proper address card. It knows which part is the file name, which part is the extension, and which folder it lives in, and you can ask it questions like “do you actually exist on disk?”
📄 pathlib: the modern way to handle paths
from pathlib import Path
# Build paths with the / operator (works on Windows, Mac, Linux)
project = Path.home() / "projects" / "webapp"
config = project / "config" / "settings.json"
print(f"Home directory: {Path.home()}")
print(f"Config path: {config}")
print(f"File name: {config.name}") # settings.json
print(f"Stem: {config.stem}") # settings
print(f"Extension: {config.suffix}") # .json
print(f"Parent: {config.parent}") # .../config
print(f"Exists? {config.exists()}") # False, we never created it
# List every .py file in the current folder
py_files = list(Path(".").glob("*.py"))
print(f"Python files here: {len(py_files)}")
▶ Output (Windows, your home path and file count will differ)
Home directory: C:\Users\Rahul Config path: C:\Users\Rahul\projects\webapp\config\settings.json File name: settings.json Stem: settings Extension: .json Parent: C:\Users\Rahul\projects\webapp\config Exists? False Python files here: 11
What happened here: Notice we never glued strings together with slashes by hand. The / operator builds the path for you, and Python prints it with the right separator for your operating system: backslashes on Windows, forward slashes on Mac and Linux. The same code runs everywhere without a single if platform == check. The home directory and the file count come from the machine this ran on, so yours will look different. Everything else (.name, .stem, .suffix, .parent) is pure path logic and will match exactly.
📄 shutil: heavy-duty file operations
import shutil
# How much room is left on the drive?
total, used, free = shutil.disk_usage("/")
print(f"Total: {total // (1024**3)} GB")
print(f"Used: {used // (1024**3)} GB")
print(f"Free: {free // (1024**3)} GB")
# These are commented out so the demo does not touch your files:
# shutil.copy("source.txt", "backup.txt") # Copy a file
# shutil.copytree("src_dir", "backup_dir") # Copy a whole folder tree
# shutil.move("old_loc/file.txt", "new_loc/") # Move a file
# shutil.rmtree("temp_dir") # Delete a folder tree (no undo)
▶ Output (numbers depend on your disk)
Total: 476 GB Used: 388 GB Free: 87 GB
What happened here: shutil is the module for the file jobs that feel like chores: copying, moving, and deleting whole folders. Writing that yourself with a loop is fiddly and easy to get wrong. The // (1024**3) bit just turns raw bytes into gigabytes (1024 bytes make a kilobyte, and so on three times up). One warning worth tattooing somewhere: shutil.rmtree deletes a folder and everything inside it with no trip to the recycle bin. There is no undo.
Data Handling: json, csv, collections
The examples from here on reuse one running cast: a small dev team where Rahul is the lead, Niranjan works on the backend, Viraj handles the frontend, and teammates Anvi, Anvay, Aviraj, and Aditi show up as we go. First job for this team: turning their data into JSON and back.
📄 json: the universal data exchange format
import json
team = {
"project": "TechnoScripts",
"members": [
{"name": "Rahul", "role": "lead", "age": 28},
{"name": "Niranjan", "role": "backend", "age": 26},
{"name": "Viraj", "role": "frontend", "age": 25}
],
"active": True
}
# Python dict to JSON text (dumps = "dump string")
json_str = json.dumps(team, indent=2)
print(json_str[:120] + "...")
# JSON text back to a Python dict (loads = "load string")
parsed = json.loads(json_str)
print(f"\nProject: {parsed['project']}")
print(f"Lead: {parsed['members'][0]['name']}")
▶ Output
{
"project": "TechnoScripts",
"members": [
{
"name": "Rahul",
"role": "lead",
"age": 28
},...
Project: TechnoScripts
Lead: Rahul
What happened here: json is the translator between Python and the rest of the world. dumps turns a dict into a text string you can save to a file or send over the network, and loads turns that text back into a dict. The indent=2 makes the output human-readable with two-space nesting. One detail beginners miss: Python’s True becomes lowercase true in JSON, and None becomes null. The [:120] slice just chops the string at 120 characters so the demo does not flood your screen, which is why the output cuts off partway through, right after the first member.
📄 collections: Counter, defaultdict, deque
from collections import Counter, defaultdict, deque
# Counter: tally up anything hashable in one line
languages = ["Python", "Java", "Python", "Go", "Python", "Java", "Rust"]
counts = Counter(languages)
print(f"Language counts: {counts}")
print(f"Top 2: {counts.most_common(2)}")
# defaultdict: a dict that hands you a fresh empty list instead of a KeyError
team_projects = defaultdict(list)
team_projects["Anvi"].append("API Gateway")
team_projects["Anvi"].append("Auth Service")
team_projects["Aviraj"].append("Dashboard")
print(f"\nAnvi's projects: {team_projects['Anvi']}")
print(f"Unknown member: {team_projects['nobody']}") # Returns [], not a KeyError
# deque: a list with a memory limit, drops the oldest when full
recent_searches = deque(maxlen=3)
recent_searches.append("python itertools")
recent_searches.append("flask tutorial")
recent_searches.append("docker compose")
recent_searches.append("kubernetes pods") # The oldest item falls off the back
print(f"\nRecent searches: {list(recent_searches)}")
▶ Output
Language counts: Counter({'Python': 3, 'Java': 2, 'Go': 1, 'Rust': 1})
Top 2: [('Python', 3), ('Java', 2)]
Anvi's projects: ['API Gateway', 'Auth Service']
Unknown member: []
Recent searches: ['flask tutorial', 'docker compose', 'kubernetes pods']
What happened here: These three are the upgrades you wish the plain dict and list shipped with. Counter is like a tally sheet at the door of a party, every time a guest walks in you add a mark next to their name, and most_common(2) tells you the two most frequent in seconds. defaultdict(list) fixes the most annoying part of grouping data, here each teammate (Anvi, Aviraj) gets their own project list: with a normal dict, d["nobody"] blows up with a KeyError, but here it quietly hands back an empty list so you can append without checking first.
And deque with maxlen=3 behaves like a three-slot shelf: push a fourth book on and the oldest one falls off the other end, which is exactly how a “recent searches” list should work.
Iteration: itertools, functools
📄 itertools: lazy iteration utilities
from itertools import chain, combinations, groupby, islice
# chain: stitch several iterables into one stream
frontend = ["HTML", "CSS", "JS"]
backend = ["Python", "Go", "Rust"]
all_skills = list(chain(frontend, backend))
print(f"All skills: {all_skills}")
# combinations: every unique pair, no repeats, order ignored
team = ["Rahul", "Anvay", "Aditi"]
pairs = list(combinations(team, 2))
print(f"Review pairs: {pairs}")
# islice: take a slice of any iterator, even an endless one
squares = (x**2 for x in range(1000)) # x starts at 0, so 0**2 = 0
first_five = list(islice(squares, 5))
print(f"First 5 squares: {first_five}")
# groupby: group neighbours that share a key (sort by that key first!)
scores = [("A", 95), ("A", 88), ("B", 72), ("B", 68), ("A", 91)]
scores.sort(key=lambda x: x[0]) # Without this, the two A groups stay split
for grade, group in groupby(scores, key=lambda x: x[0]):
print(f"Grade {grade}: {[s[1] for s in group]}")
▶ Output
All skills: ['HTML', 'CSS', 'JS', 'Python', 'Go', 'Rust']
Review pairs: [('Rahul', 'Anvay'), ('Rahul', 'Aditi'), ('Anvay', 'Aditi')]
First 5 squares: [0, 1, 4, 9, 16]
Grade A: [95, 88, 91]
Grade B: [72, 68]
What happened here: itertools is a box of patient iterators. They do not build giant lists in memory, they hand you one item at a time, which is how islice can safely take 5 values out of a generator that could produce a thousand. Watch that squares line: range(1000) starts at 0, so the very first square is 0**2 = 0, and the first five come out as [0, 1, 4, 9, 16], not [1, 4, 9, 16, 25].
That off-by-one catches people constantly. The other catch lives in groupby: it only groups items that sit next to each other, so you must sort by the same key first. Skip the sort and you get two separate “A” groups instead of one.
📄 functools: lru_cache and partial
from functools import lru_cache, partial
import time
# lru_cache: remember past results so repeats are free
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
start = time.perf_counter()
result = fibonacci(100)
elapsed = time.perf_counter() - start
print(f"fib(100) = {result}")
print(f"Time: {elapsed:.6f}s (cached, so it barely registers)")
# partial: lock in some arguments now, fill the rest later
def greet(greeting, name):
return f"{greeting}, {name}!"
hello = partial(greet, "Hello")
namaste = partial(greet, "Namaste")
print(hello("Aditi"))
print(namaste("Anvay"))
▶ Output
fib(100) = 354224848179261915075 Time: 0.000227s (cached, so it barely registers) Hello, Aditi! Namaste, Anvay!
What happened here: @lru_cache is a sticky note for function results. The first time fibonacci(100) runs it does the work, then it pins the answer to the wall. Ask for it again and it reads the note instead of recomputing. Without the cache, fibonacci(100) would branch into astronomically many repeated calls and effectively never finish. With it, the whole thing lands in a fraction of a millisecond. Your exact time will differ run to run, that is fine, the point is it is tiny.
partial is the other handy tool: it takes a function and freezes one argument, so hello = partial(greet, "Hello") gives you a new function that only needs a name. Same idea as a coffee order saved as “the usual”.
System: sys, subprocess, argparse
Quick way to keep these two straight: sys is your program looking in the mirror (what version am I running on, what arguments was I started with), while subprocess is your program picking up the phone to ask another program to do a job and report back.
📄 sys: runtime info and control
import sys
print(f"Python version: {sys.version}")
print(f"Platform: {sys.platform}")
print(f"Max int size: {sys.maxsize}")
print(f"Recursion limit: {sys.getrecursionlimit()}")
print(f"Script arguments: {sys.argv}")
# sys.exit(0) # Quit with a success code
# sys.exit(1) # Quit with an error code
▶ Output
Python version: 3.14.6 (tags/v3.14.6:c63aec6, Jun 10 2026, 10:26:10) [MSC v.1944 64 bit (AMD64)] Platform: win32 Max int size: 9223372036854775807 Recursion limit: 1000 Script arguments: ['7_sys.py']
What happened here: Where os talks to your computer, sys talks to the Python interpreter running your code. The version string is exactly what the interpreter reports, so it changes with your Python build (this ran on Python 3.14.6 on Windows, hence win32 and the MSC compiler tag). sys.argv is the list of words you typed after python on the command line, which is how scripts read their own arguments. And sys.maxsize is not the biggest integer Python can hold (Python ints are unbounded), it is the largest index a list can have on this machine.
📄 subprocess: run other programs from Python
import subprocess
import sys
# Run a command and capture what it prints
result = subprocess.run(
[sys.executable, "--version"], # sys.executable = the python running this
capture_output=True,
text=True # text=True gives strings, not raw bytes
)
print(f"stdout: {result.stdout.strip()}")
print(f"Return code: {result.returncode}") # 0 means success
# Pass arguments as a list, no shell needed (safer, cross-platform)
result = subprocess.run(
[sys.executable, "-c", "print('Hello from a subprocess')"],
capture_output=True,
text=True
)
print(f"Captured: {result.stdout.strip()}")
▶ Output
stdout: Python 3.14.6 Return code: 0 Captured: Hello from a subprocess
What happened here: subprocess.run launches another program, waits for it to finish, and hands you back what it printed plus its exit code. A return code of 0 means “all good”, anything else means trouble. Notice we pass the command as a list of separate arguments rather than one big string. That is the safe habit: it sidesteps shell=True, which can let user input sneak in extra commands (a real security hole) and which also quotes things differently on Windows versus Mac and Linux. Using sys.executable instead of the literal "python" guarantees you call the exact same interpreter that is running your script.
The third module in this group, argparse, deserves a quick word even without a demo. Reading sys.argv by hand works for one or two arguments, but the moment your script grows flags like --verbose or --output file.txt, switch to argparse: you declare each argument once and it handles parsing, type conversion, error messages, and a free -h help screen.
Date, Time & Math: datetime, random, math
Doing date math by hand is like counting change in a currency where every note has a different value: 30 days here, 31 there, 28 or 29 in February. It is exactly the kind of arithmetic humans fumble and computers should own, and datetime owns it.
📄 datetime: dates, times, and durations
from datetime import datetime, timedelta, date
now = datetime.now()
print(f"Now: {now.strftime('%Y-%m-%d %H:%M')}")
birthday = date(1998, 5, 15)
age = (date.today() - birthday).days // 365 # rough age in whole years
print(f"Aviraj's age: {age}")
# timedelta is "an amount of time" you can add to a date
deadline = now + timedelta(days=14, hours=8)
print(f"Deadline: {deadline.strftime('%B %d, %Y at %I:%M %p')}")
# strptime parses a string into a real date object
release = datetime.strptime("2026-03-15", "%Y-%m-%d")
print(f"Release date: {release.date()}")
▶ Output (run on 2026-06-21, your “Now” and deadline will differ)
Now: 2026-06-21 15:26 Aviraj's age: 28 Deadline: July 05, 2026 at 11:26 PM Release date: 2026-03-15
What happened here: Date math is the kind of thing people get painfully wrong by hand (leap years, month lengths, the 31st that does not exist). datetime handles all of it. Subtract one date from another and you get a timedelta, an “amount of time” with a .days count. Add a timedelta to a date and it rolls the calendar forward correctly, which is how the deadline jumps cleanly from June into July. strftime formats a date into text (the %Y-%m-%d style codes), and strptime does the reverse, reading text back into a real date.
The first two lines depend on the clock, so they will read differently when you run it. The age divides total days by 365, so it is close but not birthday-exact.
📄 random: random selection and shuffling
import random
random.seed(42) # Same seed = same "random" results every run
team = ["Rahul", "Niranjan", "Viraj", "Anvi", "Anvay", "Aditi"]
print(f"Random reviewer: {random.choice(team)}") # pick one
print(f"Two reviewers: {random.sample(team, 2)}") # pick two, no repeats
random.shuffle(team) # shuffles the list in place
print(f"Shuffled order: {team}")
print(f"Random int 1-100: {random.randint(1, 100)}")
print(f"Random float 0-1: {random.random():.4f}")
▶ Output
Random reviewer: Aditi Two reviewers: ['Rahul', 'Aditi'] Shuffled order: ['Anvi', 'Anvay', 'Rahul', 'Niranjan', 'Viraj', 'Aditi'] Random int 1-100: 95 Random float 0-1: 0.1025
What happened here: The trick that surprises people is random.seed(42). “Random” numbers from a computer are not truly random, they come from a formula that starts at a seed. Plant the same seed and you get the exact same sequence every single time, which is why the output above is identical on every run and on your machine too. That is gold for testing: you want a shuffle you can reproduce. Remove the seed line and the results change on every run. Note random.sample picks without repeats (good for “pick 2 different reviewers”), while random.choice can pick the same item twice across calls.
Rounding out this group, the math module covers the number work that plain operators cannot: math.sqrt, math.floor and math.ceil, math.factorial, math.log, and the trig functions. If you catch yourself writing x ** 0.5 or hand-rolling a rounding trick, math already has a tested, faster version.
The “Python Way” Callouts
Two pieces of code can both work and still feel worlds apart. Experienced Python developers reach for the idiomatic option almost on reflex. Here are two habits worth copying early.
💡 Use pathlib instead of os.path
import os
from pathlib import Path
# Old way: os.path, gluing strings together
full_path = os.path.join(os.path.expanduser("~"), "projects", "app.py")
# New way: pathlib, real path objects and the / operator
full_path = Path.home() / "projects" / "app.py"
# pathlib is the modern standard. Use it for all new code.
💡 Use logging instead of print for debugging
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Instead of: print(f"Processing user {user_id}")
logger.info("Processing user %s", "Rahul")
logger.warning("Rate limit approaching: %d/100", 85)
logger.error("Failed to connect to database")
# Logging gives you levels, formatting, file output, and rotation. print gives you none of that.
▶ Output
INFO:__main__:Processing user Rahul WARNING:__main__:Rate limit approaching: 85/100 ERROR:__main__:Failed to connect to database
What happened here: Every message comes out tagged with its level (INFO, WARNING, ERROR) and where it came from. That label is the whole point. When something breaks in production at 2am, you can show only the errors and ignore the chatter, something a wall of print statements can never do. Later you can send these same messages to a file, rotate them daily, or ship them to a monitoring tool without touching a single logger.info line. Note the %s and %d style: logging fills those in only if the message actually gets shown, which saves a little work on messages you filter out.
Common Mistakes
❌ Mistake: Installing what’s already built in
# No need to pip install a JSON library, json is built in # No need to pip install a CSV library, csv is built in # No need to write your own LRU cache, functools.lru_cache exists # No need to pip install a path library, pathlib has shipped since 3.4 # Check the standard library FIRST, before you reach for pip
❌ Mistake: Using os.path when pathlib exists
# os.path still works, but pathlib is cleaner, safer, and cross-platform # os.path.join() returns plain strings, which are easy to mangle by accident # Path objects validate paths, support the / operator, and carry helpful methods
Conclusion
The Python standard library is huge, and you just toured the corner of it that earns its keep every day. Pathlib, json, csv, collections, itertools, functools, datetime, random, sys, argparse: these alone cover file handling, data wrangling, iteration, system work, and command-line tools. You will not remember every method, and you do not need to. Remember the names, remember roughly what each one is for, and let “batteries included” do the rest. The next time you are about to pip install something or hand-roll a workaround, pause and check the toolbox first. The answer is often already there.
You have leaned on modules, packages, and libraries all the way through this series, sometimes without stopping to name them. Next, in the module vs package tutorial, you finally get the clean answer to the question every beginner trips over: what exactly is the difference between a module, a package, a library, and a framework? And if you want the full map of everything covered so far, browse the Python + AI/ML tutorial series home.
Practice Exercises
- Exercise 1: List all files with sizes using os and pathlib.
- Exercise 2: Calculate days until New Year with datetime.
- Exercise 3: Convert between JSON and CSV using json, csv, pathlib.
Frequently Asked Questions
What is the Python standard library?
The Python standard library is a collection of 200+ modules that ship with every Python installation. You do not need to pip install them, they are already available. It includes tools for file I/O, data handling, math, dates, regular expressions, and much more.
What is the most useful Python standard library module?
It depends on your work, but pathlib (file paths), json (data exchange), collections (specialized containers), and itertools (iteration tools) are useful almost everywhere. For web developers, add logging and subprocess. For data work, add csv and datetime.
Should I use pathlib or os.path?
Use pathlib for all new code. It is object-oriented, cross-platform, and more readable than os.path string manipulation. os.path still works and you will see it in older codebases, but pathlib has been the recommended approach since Python 3.4.
What is functools.lru_cache?
@lru_cache is a decorator that automatically caches function results. When you call the function with the same arguments again, it returns the cached result instead of recomputing. LRU stands for ‘Least Recently Used’, so it evicts the oldest entries when the cache fills up.
How do I find all modules in the standard library?
Run help('modules') in the Python REPL to list every available module. Or check the official documentation at docs.python.org/3/library/. The dir(module) function lists everything inside one specific module.
What is the difference between os and sys modules?
os talks to the operating system: file operations, environment variables, process management. sys talks to the Python interpreter itself: command-line arguments, the module search path, stdin and stdout, recursion limits. A quick way to remember it: os is your computer, sys is your Python runtime.
Interview Questions on the Python Standard Library
These come from real screens and onsites. Practice answering before you read each answer.
Q: Your script builds a command with subprocess.run(f”convert {filename}”, shell=True) and a security review flags it. What is the risk and the fix?
The risk is shell injection. If filename comes from a user and contains something like photo.png; rm -rf ~, the shell happily runs the extra command. The fix is to drop shell=True and pass the command as a list of separate arguments: subprocess.run(["convert", filename]). Each list item is delivered to the program as one argument, so nothing in the filename can be interpreted as a new command.
Q: A long-running Application Programming Interface (API) service decorates a lookup function with @lru_cache(maxsize=None), takes user IDs as arguments, and memory keeps climbing. What do you check first?
Check the cache size first: maxsize=None means unbounded, so every distinct user ID ever seen stays in memory forever. Call lookup.cache_info() to see how many entries have piled up. The fix is a bounded cache like @lru_cache(maxsize=1024) so old entries get evicted. Also watch for lru_cache on instance methods: the cache holds a reference to self, which keeps whole objects alive.
Q: Why can itertools.groupby return two separate groups for the same key, and how do you prevent it?
groupby only groups items that sit next to each other in the input, it never scans the whole sequence. So if the data is unsorted, the same key can appear in several runs and you get several groups for it. Sort the data by the exact same key function before calling groupby, and each key collapses into one group.
Q: When would you pick collections.deque over a plain list?
Pick deque when you add or remove items at both ends. deque.popleft() and appendleft() are O(1), while list.pop(0) is O(n) because every remaining element has to shift over. That makes deque the right tool for queues and sliding windows, and maxlen gives you a free ring buffer, like the recent-searches example in this post. For random access by index, a list is still better.
Q: Is the random module suitable for generating password reset tokens?
No. random uses the Mersenne Twister algorithm, which is deterministic: an attacker who observes enough outputs can predict the next ones. For anything security-related, use the secrets module, also in the standard library, for example secrets.token_urlsafe(32). Keep random for simulations, sampling, and shuffling where reproducibility is a feature, not a bug.
Q: Your app stores timestamps with datetime.now() and users in other time zones see wrong times. What went wrong and how do you fix it?
datetime.now() with no argument returns a naive datetime: it has no time zone attached, so it silently means “whatever zone the server is in”. Store aware datetimes instead: datetime.now(timezone.utc) from the datetime module, then convert to each user’s zone at display time with zoneinfo.ZoneInfo, for example .astimezone(ZoneInfo("Asia/Kolkata")). The rule of thumb is store in UTC, convert at the edges.
Further reading: the official Python documentation is the authoritative source on this.
Related Posts
Previous: Python: Packages, __init__.py, Relative Imports
Next: Python: Module vs Package vs Library vs Framework, What’s the Difference?
Series Home: Python + AI/ML Tutorial Series

No comment