Safeguard
DevSecOps

Advanced Python: The Security-Focused Patterns Senior Developers Should Master

Advanced Python is not about clever one-liners. The patterns that separate senior engineers are the ones that keep code safe: safe deserialization, controlled subprocess calls, and disciplined dependency use.

Priya Mehta
DevSecOps Engineer
6 min read

Advanced Python, in any codebase that ships to real users, means writing code that is not just elegant but hard to exploit — and most of the language's genuinely dangerous footguns hide behind features that look convenient. The metaclass tricks and decorator gymnastics that dominate "advanced Python" listicles are fun, but the patterns that actually distinguish a senior engineer are the ones that keep pickle, subprocess, eval, and your dependency tree from turning into a remote-code-execution path.

This is a tour of the Python features that are powerful enough to be dangerous, and how to use them without opening a hole.

Deserialization: pickle is arbitrary code execution

The single most misused feature in the language is pickle. It is not a data format; it is a program that runs when you load it. Unpickling data from any source you do not fully control is equivalent to running that source's code on your machine.

import pickle

# DANGEROUS: if `data` came from a user, a cache, a queue,
# or a file an attacker can touch, this can execute arbitrary code.
obj = pickle.loads(data)

The __reduce__ method lets a pickled object specify a callable and arguments to invoke on load, which is the exact mechanism an attacker abuses. The fix is not "validate the pickle" — you can't reliably do that. The fix is to not use pickle for untrusted data at all.

Use a data-only format instead:

import json

# JSON carries data, not code. It cannot execute anything on load.
obj = json.loads(data)

For structured objects, reach for a schema-validating library like pydantic or a format like msgpack with typed decoding. Reserve pickle for data that never crosses a trust boundary — say, a cache you write and read within the same trusted process.

Subprocess without shell injection

Shelling out is common in ops-heavy Python, and shell=True is where it goes wrong.

import subprocess

# DANGEROUS: a filename like "; rm -rf ~" becomes a second command.
subprocess.run(f"convert {user_filename} out.png", shell=True)

Passing a string to the shell means the shell parses it, and any shell metacharacters in user input become instructions. The safe form passes a list of arguments so no shell is involved and the arguments are never re-parsed:

import subprocess

# SAFE: arguments are passed directly to execve, not to a shell.
subprocess.run(["convert", user_filename, "out.png"], check=True)

Drop shell=True unless you genuinely need shell features, and if you do, sanitize with shlex.quote() and understand you are now responsible for the shell's entire grammar.

eval, exec, and dynamic attribute access

eval() and exec() on anything derived from user input are almost always a mistake — they run arbitrary Python. If you find yourself reaching for them to parse data, you want ast.literal_eval() instead, which evaluates only literals (strings, numbers, tuples, lists, dicts) and cannot call functions.

import ast

# SAFE: parses a Python literal without executing arbitrary code.
value = ast.literal_eval(user_supplied_string)

The subtler cousin is dynamic attribute access. getattr(obj, user_string) looks harmless until user_string is "__class__" and an attacker walks the object graph to something dangerous. Whitelist the allowed attribute names explicitly rather than trusting input to name them.

Type hints and validation as a security tool

Advanced Python leans hard on typing, and typing is not just a linting nicety — it is a place to enforce a trust boundary. A function that declares def process(payload: dict[str, int]) still receives whatever the caller passes at runtime, because Python's hints are not enforced. Pairing hints with a validating layer at your input boundaries turns "I expect an int" into "I reject anything that isn't one."

from pydantic import BaseModel, field_validator

class Order(BaseModel):
    quantity: int
    sku: str

    @field_validator("quantity")
    @classmethod
    def positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("quantity must be positive")
        return v

Validate untrusted input once, at the edge, into a typed object; trust the typed object everywhere inside. That discipline eliminates a whole class of "we assumed it was an int" bugs.

Dependency discipline

The most likely vulnerability in your advanced Python application is not in your code — it is in the 200 transitive packages you pulled in without reading. Python's install-time execution model means a malicious package can run code the moment pip install touches it, and typosquatting on PyPI is a live, ongoing problem.

Practical hygiene:

  • Pin and hash-lock. Use pip install --require-hashes with a fully pinned requirements.txt (or a poetry.lock / uv.lock) so a compromised upstream can't silently swap a version.
  • Audit the transitive tree, not just direct deps. A vulnerability three levels deep still runs in your process. A software composition analysis tool can flag a known-vulnerable transitive dependency that you never chose directly.
  • Watch for install-time scripts. Prefer wheels over sdists where possible; a wheel does not run a setup.py at install time.

If you want to build the underlying mental model of how supply-chain risk propagates through a language ecosystem, our academy has a track on exactly that.

Concurrency without shared-state bugs

Advanced Python inevitably means concurrency, and the security-adjacent risk here is state corruption under race conditions — a check-then-act on shared data that two coroutines interleave. Prefer immutable data passed between tasks, use asyncio.Lock around genuinely shared mutable state, and never assume the GIL protects a multi-step operation (it protects single bytecodes, not your logic).

The theme across all of this is the same: Python's expressiveness lets you do dangerous things concisely. Senior-level Python is knowing which conveniences carry a trust-boundary cost and refusing to pay it with untrusted input.

FAQ

Why is pickle considered dangerous in advanced Python?

pickle.loads() can execute arbitrary code because a pickled object can specify a callable to invoke on load via __reduce__. Never unpickle data from an untrusted or attacker-influenced source; use JSON or a schema-validated format instead.

How do I run shell commands safely in Python?

Pass a list of arguments to subprocess.run(["cmd", arg1, arg2]) and avoid shell=True. The list form sends arguments directly to the process without a shell parsing them, which prevents command injection through metacharacters in user input.

What should I use instead of eval() for parsing input?

Use ast.literal_eval(), which evaluates only Python literals and cannot call functions or execute arbitrary code. Reserve eval() and exec() for trusted, developer-authored input, never for anything derived from users.

Do Python type hints improve security?

Hints alone are not enforced at runtime, so they do not stop bad input by themselves. Paired with a validation layer like pydantic at your input boundaries, they let you reject malformed data once at the edge and trust typed objects internally, which removes a large class of assumption bugs.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.