Pythonic Way of Writing Code

I keep coming back to this idea that Python code either reads like English or it reads like gibberish. There is no middle ground. The difference between the two comes down to whether someone writes Python the way Python wants to be written.

Pythonic code is not about using Python syntax. It is about using the language the way its designers intended. I see junior developers reach for loops and manual indexing when Python has built-in solutions that do the same thing in one line. Let me show you what I mean with examples I use in production.

TLDR

  • Replace string concatenation with f-strings
  • Use enumerate() instead of range(len())
  • Reach for list comprehensions instead of append() in loops
  • Merge dictionaries with the | operator or ** unpacking
  • Use context managers instead of manual cleanup

What Makes Code Pythonic

Pythonic means idiomatic. It means writing code that takes advantage of how Python actually works rather than porting patterns from other languages. The term gets thrown around a lot, but I think the simplest definition is this: if you read it aloud and it sounds like English, you are probably doing it right.

PEP 8 is the style guide that defines most of these conventions. I do not follow every single rule, but I follow most of them. The guide covers naming, spacing, and code layout. The goal is not perfection. The goal is code that other developers can read without needing to decode it first.

Naming Conventions From PEP 8

PEP 8 is the official style guide for Python. I use it as a reference when I am not sure how to name something. Here is the core pattern I follow every day.

TypeConventionExample
Modulessnake_casedb_utils, api_client
Functionssnake_caseget_user, parse_config
ClassesPascalCaseOrderDetails, UserProfile
ConstantsUPPER_SNAKE_CASEMAX_RETRIES, API_KEY
Variablessnake_caseuser_id, total_price

Following these conventions means I can look at any variable name and immediately know what type of thing it is. I do not have to search for a definition or scroll through the file to figure it out.

F-Strings Over String Concatenation

Before Python 3.6, I used the + operator to build strings. It worked, but it looked messy. I also used the % format specifier for years because that is what I saw in older codebases. Here is the progression I went through.


name = "Alice"
age = 28
role = "engineer"

# The old way: concatenation with +
result = name + " is " + str(age) + " years old and works as a " + role
print(result)

# The older way: % formatting
result = "%s is %d years old and works as a %s" % (name, age, role)
print(result)

# The Pythonic way: f-string from Python 3.6
result = f"{name} is {age} years old and works as a {role}"
print(result)

The f-string version reads like a sentence. I can see the template and the values clearly. The other versions force me to parse through operators and type conversions to understand what is happening. F-strings also handle any type automatically, so I do not have to remember to call str() on integers or floats.

enumerate() Instead of range(len())

I used to write for loops with range(len()) everywhere. It works, but it is verbose. When I need both the index and the value, I reach for enumerate().


fruits = ["apple", "banana", "cherry"]

# The old way: range and len
for i in range(len(fruits)):
    print(i, fruits[i])

# The Pythonic way: enumerate
for i, fruit in enumerate(fruits):
    print(i, fruit)

enumerate() returns a tuple of (index, value) on each iteration. I do not have to manage the index manually. The code is shorter and less error-prone because I am not incrementing a counter that could drift out of sync.

List Comprehensions Instead of Append

List comprehensions are one of the features that make Python unique. I use them whenever I convert one list into another. The key is not to overuse them. If a comprehension gets too long or nested, I fall back to a regular loop.


numbers = [1, 2, 3, 4, 5]

# The old way: loop with append
squares = []
for n in numbers:
    squares.append(n ** 2)
print(squares)

# The Pythonic way: list comprehension
squares = [n ** 2 for n in numbers]
print(squares)

# With a condition: only even numbers
evens = [n ** 2 for n in numbers if n % 2 == 0]
print(evens)

I use list comprehensions most often for simple conversions. They are easy to read at a glance. When I need to do something complex with side effects, I use a regular loop with a comment explaining what is happening.

Merging Dictionaries the Right Way

Dictionary merging has changed a few times in Python. I remember copying keys one by one in a loop. Python 3.5 introduced ** unpacking, and Python 3.10 introduced the | operator. Both are clean and I use them daily.


defaults = {"theme": "dark", "language": "en", "timeout": 30}
user_config = {"theme": "light", "notifications": True}

# Before Python 3.9: loop with update
merged = defaults.copy()
merged.update(user_config)
print(merged)

# Python 3.5+: ** unpacking
merged = {**defaults, **user_config}
print(merged)

# Python 3.10+: | operator
merged = defaults | user_config
print(merged)

The | operator is my default now. It is intuitive and reads like set union. When I see x | y, I know the second dict overwrites the first. The ** unpacking syntax still works in older Python versions, so I use that when I need to support Python 3.5 through 3.9.

Context Managers for Resource Handling

I used to open files with open() and close() manually. I also wrapped database connections in try-finally blocks. Context managers with the with statement handle this automatically. I never have to worry about forgetting to close a file or connection.


# The old way: manual open and close
f = open("data.txt", "w")
f.write("hello")
f.close()  # What if write() raises an exception?

# The Pythonic way: context manager
with open("data.txt", "w") as f:
    f.write("hello")
# File is automatically closed when we exit the with block

# Same applies to database connections
import sqlite3
with sqlite3.connect("app.db") as conn:
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM users LIMIT 5")
    rows = cursor.fetchall()
    print(rows)

Context managers call __enter__ when entering the block and __exit__ when leaving it, even if an exception occurs. I use them for file I/O, database connections, and locks. It is one of those patterns that eliminates an entire class of bugs without any extra code.

Generator Expressions for Memory Efficiency

When I process large datasets, memory matters. A list comprehension builds everything in memory at once. A generator expression yields one item at a time. I use generators when I am processing files or pipelines where I do not need the full collection in memory at once.


numbers = range(1000000)

# List comprehension: builds full list in memory
squares_list = [x ** 2 for x in numbers]
print(f"List size: {len(squares_list)} elements")

# Generator expression: yields one at a time
squares_gen = (x ** 2 for x in numbers)
print(f"Generator: {squares_gen}")

# sum() works the same way on both
print(sum(x ** 2 for x in numbers))  # generator version

The generator version avoids allocating a million integers. For large ranges, this makes a real difference. I use generators inside function calls like sum(), min(), max(), and any() because those functions consume the generator lazily. The trade-off is that generators can only be iterated once.

FAQ

Q: Does Pythonic code always mean shorter code?

No. Pythonic code means code that is readable and idiomatic. Sometimes a list comprehension makes things clearer. Sometimes a regular loop with a comment is better. I judge by whether I can understand it a month later without any context.

Q: Should I rewrite all my old Python code to be Pythonic?

Only when I need to modify that code anyway. Rewriting working code introduces bugs. I focus on writing new code the right way and refactoring old code when I touch it for other reasons.

Q: Can code be too Pythonic?

Yes. I have seen one-liners that are clever but impossible to debug. The goal is clarity. If a list comprehension requires a nested condition and three operations, I split it into multiple lines or use a regular loop with a comment.

Q: Does following PEP 8 matter for small scripts?

It matters for consistency. If I write small scripts that might grow, starting with good habits saves refactoring time later. PEP 8 exists because the Python community found that certain conventions reduce cognitive load across large codebases.

I keep coming back to the same principle: write code for humans first, computers second. Pythonic code is readable code. That is the whole idea. The language gives you tools to express intent clearly. Using those tools is what separates code that I enjoy reading from code that I have to fight through.

Aman Raj
Aman Raj
Articles: 19