30 Simple Tricks to Instantly Level Up Your Python Skills

These bite-sized Python power-ups will help you write cleaner, faster, and more Pythonic code — whether you’re building scripts, APIs, or…

30 Simple Tricks to Instantly Level Up Your Python Skills
Photo by Amol Tyagi on Unsplash

You’re not writing bad Python — but you could be writing brilliant Python.

30 Simple Tricks to Instantly Level Up Your Python Skills

These bite-sized Python power-ups will help you write cleaner, faster, and more Pythonic code — whether you’re building scripts, APIs, or full-blown apps.

Stop Writing Okay Python — Start Writing Great Python

Python is famously beginner-friendly — but the real fun starts when you move past the basics and begin to truly master the language.

These 30 simple but effective tricks are like cheat codes for writing smarter Python. They’re the tips I wish someone had handed me early on — and the ones I still reach for in production code, data pipelines, and side projects.

Some will help you write cleaner code. Others will save you lines. A few might even blow your mind a little.


1. Use List Comprehensions Like a Pro

A list comprehension is a concise way to create a list in Python. Instead of using a multi-line for loop to build a list, you can do it in a single line.

It improves readability, performance, and makes your code look more Pythonic.

Traditional Way (Using a Loop)

Here’s the regular way you might generate a list of squares from numbers 0 through 9:

squares = [] 
for i in range(10): 
    squares.append(i ** 2)

That works just fine — but it’s 3 lines long, and a bit verbose.

Pythonic Way (Using List Comprehension)

Here’s the list comprehension version:

squares = [i ** 2 for i in range(10)]

That does exactly the same thing — in just one line. It’s shorter, clearer, and easier to understand at a glance.

Example with a Condition

Let’s say you only want even squares from 0 to 9:

even_squares = [i ** 2 for i in range(10) if i % 2 == 0]

This filters out the odd numbers before squaring them.

List comprehensions are one of the best tools in Python for clean, readable code. They’re great for:

Transforming lists
Filtering items
Performing simple calculations

Once you start using them, they’ll become second nature — and your code will look much more Pythonic.

2. Master enumerate() Instead of Using Index Counters

The built-in function enumerate() allows you to loop through a sequence (like a list or tuple) and automatically keep track of the index of each item.

It gives you both the index and the item in one go — cleanly and Pythonically.

Why Not Just Use a Manual Index?

Let’s say you have a list of items, and you want to print both the index and the item.

Old, Clunky Way (Manual Indexing):

items = ["apple", "banana", "cherry"] 
i = 0 
for item in items: 
    print(i, item) 
    i += 1
This works, but it’s repetitive.
You have to manually maintain the counter i.
It’s prone to errors, especially in larger loops or nested ones.

The Pythonic Way: enumerate()

items = ["apple", "banana", "cherry"] 
for i, item in enumerate(items): 
    print(i, item)
enumerate(items) returns a tuple (index, item) for each loop.
Cleaner, more readable, and less error-prone.
Python does the counting for you!

Want to Start from a Custom Index?

You can tell enumerate() to start counting from any number using the start= argument.

for i, item in enumerate(items, start=1): 
    print(f"{i}. {item}") 
 
# ==== output ===== 
# 1. apple 
# 2. banana   
# 3. cherry

Use enumerate() when looping if:

You need access to both the index and the value
You want to write code that’s easy to read and maintain

It’s one of those small Python tricks that makes a big difference.

3. Unpack Like a Pythonista

Unpacking allows you to assign multiple variables at once from a sequence (like a list or tuple). Instead of doing this manually:

person = ("Alice", 30, "Germany") 
name = person[0] 
age = person[1] 
country = person[2]

You can do this in a single, elegant line:

name, age, country = ("Alice", 30, "Germany")

Python unpacks the values from the tuple and assigns them to variables based on position.

Common Examples of Unpacking

1. Tuple or List Unpacking

point = (3, 4) 
x, y = point

2. Unpacking in a Loop

pairs = [(1, 'a'), (2, 'b'), (3, 'c')] 
for number, letter in pairs: 
    print(f"{number} -> {letter}")

3. Ignoring Values with _

You can use _ as a throwaway variable for values you don’t need:

filename = "resume.pdf" 
name, _ = filename.split(".") 
 
# or  
 
person = ("Alice", 30, "Germany") 
name, _, _ = person  # Only care about the name

4. Using * to Grab “the Rest”

The * operator lets you capture multiple items:

a, *b = [1, 2, 3, 4, 5] 
print(a)  # 1 
print(b)  # [2, 3, 4, 5] 
 
# or  
 
first, *middle, last = [10, 20, 30, 40, 50] 
print(first)   # 10 
print(middle)  # [20, 30, 40] 
print(last)    # 50

“Unpacking like a Pythonista” means using this elegant Python feature to:

Write less boilerplate
Avoid ugly indexing (x[0], x[1], etc.)
Work with iterables more naturally
Ignore or collect values flexibly

It’s simple, powerful, and very Pythonic.

4. Use zip() to Loop Through Multiple Iterables

The built-in zip() function combines two or more iterables (like lists or tuples) element-wise.

Think of it like a zipper — it “zips” elements together, side by side.

Basic Example

Suppose you have two lists:

names = ["Alice", "Bob", "Charlie"] 
scores = [85, 90, 95]

If you want to print each name with its corresponding score, here’s the old-school way:

for i in range(len(names)): 
    print(names[i], scores[i])

Problems:

You manually manage indices
If the lists are not the same length, you can get errors
It’s not clean or readable

Pythonic Way with zip()

for name, score in zip(names, scores): 
    print(f"{name}: {score}")

What this does:

Pairs Alice with 85, Bob with 90, and so on
You get a tuple for each pair: (name, score)
No manual indexing required

What Does zip() Return?

zip() returns a zip object, which is an iterator — you can loop over it or convert it into a list:

pairs = list(zip(names, scores)) 
print(pairs) 
# Output: [('Alice', 85), ('Bob', 90), ('Charlie', 95)]

What Happens if the Lists Are Different Lengths?

zip() stops at the shortest iterable:

names = ["Alice", "Bob"] 
scores = [85, 90, 95] 
 
for name, score in zip(names, scores): 
    print(name, score) 
 
# Output: 
# Alice 85 
# Bob 90
zip() is the go-to tool for looping over multiple iterables in parallel.
It’s cleaner and less error-prone than indexing.
Perfect for pairing up names and values, keys and data, etc.

5. Use *args and **kwargs to Write Flexible Functions

They are special syntax used in Python function definitions to accept a variable number of arguments.

  • *args allows a function to accept any number of positional arguments (as a tuple).
  • **kwargs allows a function to accept any number of keyword arguments (as a dictionary).

In short: They make your functions dynamic and flexible.

Basic Example of *args

def greet(*names): 
    for name in names: 
        print(f"Hello, {name}!")

Now you can call greet() with any number of names:

greet("Alice") 
greet("Alice", "Bob", "Charlie")

What’s Happening?

*names collects all extra positional arguments into a tuple.
Inside the function, you can loop through them like any list or tuple.

Basic Example of **kwargs

def print_info(**info): 
    for key, value in info.items(): 
        print(f"{key}: {value}")

Now you can pass any keyword arguments:

print_info(name="Alice", age=30, country="Germany")

What’s Happening?

**info collects all extra keyword arguments into a dictionary.
You can then loop through the keys and values.

Real-World Example: Combining Both

def greet_people(*names, **options): 
    for name in names: 
        greeting = options.get("greeting", "Hello") 
        emoji = options.get("emoji", "") 
        print(f"{greeting}, {name}! {emoji}") 
 
# usage  
greet_people("Alice", "Bob", greeting="Hi", emoji="👋") 
 
# output :  
 
# Hi, Alice! 👋   
# Hi, Bob! 👋

Example: Passing Arguments to Another Function

def wrapper(func, *args, **kwargs): 
    print("Before function call") 
    result = func(*args, **kwargs) 
    print("After function call") 
    return result 
 
def add(a, b): 
    return a + b 
 
wrapper(add, 3, 5)  # Output: 8

Bonus Tip: Argument Order Matters

When using all three types of parameters, the order should be:

def my_func(pos1, pos2, *args, kw1=None, kw2=None, **kwargs): 
    pass
*args: Use when you don’t know how many positional arguments you'll get.
**kwargs: Use when you don’t know how many named arguments you'll get.
Together, they help you write reusable, extensible, and dynamic functions.

6. Use get() for Safe Dictionary Access

In Python, if you try to access a key in a dictionary that doesn’t exist, you’ll get a KeyError.

person = {"name": "Alice", "age": 30} 
 
print(person["email"])  # ❌ KeyError: 'email'

That can crash your program if you’re not careful — especially when dealing with user input, JSON data, or optional fields.

Solution: Use .get() for Safe Access

The get() method lets you safely access a key in a dictionary without risking a crash if the key isn’t there.

email = person.get("email") 
print(email)  # ✅ None (instead of error)

No KeyError! If the key is missing, it simply returns None (or whatever default value you specify).

Using .get() on dictionaries is one of the safest and cleanest ways to access data when keys might be missing. It’s especially useful when:

Working with APIs or JSON data
Accessing optional fields
Writing robust, error-proof code

7. Replace If-Chains with Dictionaries

Sometimes, especially when mapping conditions to outcomes, we end up with bulky code like this:

def handle_status(code): 
    if code == 200: 
        return "OK" 
    elif code == 404: 
        return "Not Found" 
    elif code == 500: 
        return "Server Error" 
    else: 
        return "Unknown"

It works, but:

It’s verbose
It can get messy as more conditions are added
It’s not as scalable or Pythonic

The Cleaner Way: Use a Dictionary Instead

You can simplify the same logic using a dictionary mapping:

def handle_status(code): 
    return { 
        200: "OK", 
        404: "Not Found", 
        500: "Server Error" 
    }.get(code, "Unknown")

What’s happening here?

Each status code is a key
Its corresponding message is the value
.get(code, "Unknown") safely returns the result or "Unknown" if the key doesn’t exist

Replacing if-elif-else chains with dictionaries is a Pythonic pattern that helps when:

You’re mapping one value to another (e.g., status codes, commands, actions)
You want cleaner, more flexible, and less error-prone code
You value readability and scalability

8. Use F-Strings (They’re Fast & Clean)

F-strings (short for formatted string literals) were introduced in Python 3.6 and quickly became the preferred way to insert variables into strings.

They are:

Fast (faster than older formatting methods)
Clean (very readable)
Flexible (can include expressions, not just variables)

Basic Example

Let’s say you have this:

name = "Alice" 
age = 30

Old way (using .format()):

print("My name is {} and I am {} years old.".format(name, age))

F-string way:

print(f"My name is {name} and I am {age} years old.")

Much cleaner, right?

You Can Do More Than Just Insert Variables

1. Inline Calculations

price = 49.99 
quantity = 3 
print(f"Total: ${price * quantity}") 
# Output: Total: $149.97

2. Call Functions

def shout(word): 
    return word.upper() 
 
print(f"Attention: {shout('hello')}") 
# Output: Attention: HELLO

3. Formatting Numbers

pi = 3.14159265 
print(f"Pi rounded to 2 decimals: {pi:.2f}") 
# Output: Pi rounded to 2 decimals: 3.14

4. Add Padding / Alignment

for i in range(1, 4): 
    print(f"{i:<5} | {i**2:>5}")

Use them whenever you need to include values or expressions inside a string — logging, reporting, displaying results, debugging, etc.

Bonus: Debugging with F-Strings (Python 3.8+)

user = "Alice" 
print(f"{user=}") 
# Output: user='Alice'

This prints both the variable name and value — super handy for debugging!

F-strings are:

Clean: No clunky formatting syntax
Fast: The most efficient string formatting method
Smart: They support expressions, function calls, and formatting options

They’re now the standard way to format strings in Python — if you’re not using them yet, now’s the time to start.

9. Leverage any() and all() for Clean Checks

These two functions are used to test collections of conditions, like lists, tuples, or generator expressions.

any(iterable)

Returns True if at least one element in the iterable is True.

all(iterable)

Returns True only if all elements in the iterable are True.

They’re perfect for cleaning up complex if statements, especially when you need to check multiple conditions.

Real-Life Analogy

any() is like asking:
“Is any light in the house turned on?” → True if even one light is on
all() is like asking:
“Are all the doors locked?” → True only if every single door is locked

Example 1: Using any() to Check for a Digit in a Password

password = "hello123" 
 
if any(char.isdigit() for char in password): 
    print("Password contains at least one number.")

No need to loop manually — No need for flags or early returns

Example 2: Using all() to Validate a Form

fields = ["Alice", "alice@email.com", "Germany"] 
 
if all(fields): 
    print("All fields are filled out.") 
else: 
    print("Please fill in all fields.")

Here, all() checks that none of the fields are empty strings (which are considered False in Python).

More Examples

Clean up multiple boolean checks

conditions = [user.is_active, user.is_verified, user.has_accepted_terms] 
 
if all(conditions): 
    print("User is fully onboarded")

Check if any word in a sentence is uppercase

words = "this is GREAT".split() 
 
if any(word.isupper() for word in words): 
    print("There's shouting in the text 😬")
any() returns True if at least one item is True
all() returns True if every item is True
They work with lists, tuples, sets, or generator expressions
They simplify your code and make your logic more readable

10. Use Generators for Memory-Efficient Iteration

A generator is a special kind of iterable in Python that yields values one at a time, on the fly, as needed — instead of computing and storing everything in memory upfront.

You can think of a generator like a lazy iterator:

It doesn’t generate the entire result all at once.
It pauses after each value is produced, and resumes when the next is requested.

Traditional List (Eager Evaluation)

def get_numbers(): 
    return [i for i in range(1_000_000)]
This builds a list of 1 million numbers in memory all at once.
That can be slow and memory-hungry.

Generator (Lazy Evaluation)

def get_numbers(): 
    for i in range(1_000_000): 
        yield i

Now:

nums = get_numbers()  # No numbers created yet! 
for n in nums: 
    print(n)  # Numbers are generated one at a time
Nothing is stored in memory.
Each value is produced only when needed.

How to Create a Generator

There are two main ways:

1. With yield in a function (like above)

def countdown(n): 
    while n > 0: 
        yield n 
        n -= 1

2. With a Generator Expression (like a list comprehension, but with ())

squares = (x**2 for x in range(1000000))  # Not a list! 
 
for s in squares: 
    if s > 100: 
        break 
    print(s)

Generators are one of the most powerful tools in Python for writing efficient, scalable programs:

yield = turn a function into a generator
Generator expressions = like list comprehensions, but lazy
Use them whenever you need to stream data, avoid memory spikes, or delay computation

11. Use set() to remove duplicates

A set is a built-in unordered collection of unique elements. That means:

No duplicates allowed
Items can be added, removed, or checked for existence
Very fast lookup (like a dictionary)

Here’s a quick example:

my_list = [1, 2, 2, 3, 4, 4, 5] 
unique = set(my_list) 
print(unique)  # Output: {1, 2, 3, 4, 5}

Boom — duplicates gone.

Basic Example

emails = [ 
    "a@example.com", 
    "b@example.com", 
    "a@example.com",  # duplicate 
] 
 
unique_emails = list(set(emails)) 
print(unique_emails)
Order is not preserved — unless you take extra steps (explained below).

Want to Remove Duplicates but Keep Order?

Since sets are unordered, wrapping a list in set() will scramble the order. If order matters, you can use this trick:

def dedupe_keep_order(seq): 
    seen = set() 
    return [x for x in seq if not (x in seen or seen.add(x))] 
 
names = ["Alice", "Bob", "Alice", "Charlie", "Bob"] 
print(dedupe_keep_order(names))   
 
# Output: ['Alice', 'Bob', 'Charlie']
set() removes duplicates — instantly and clearly
But it doesn’t preserve order
Use a custom function if you need to keep the order

This is one of those Python tricks that’s dead simple, but wildly useful once you get the hang of it.

12. Chain comparisons cleanly

This is one of Python’s most elegant features — and once you understand it, you’ll never want to write long comparisons the old way again.

In many programming languages (like Java, C, or JavaScript), if you want to check if a value lies between two numbers, you’d write:

if (10 < x && x < 20)

But in Python, you can write that more naturally, just like math:

if 10 < x < 20:

It reads exactly like you’d say it:
“If x is greater than 10 and less than 20”

How It Works

Python allows comparison chaining, so expressions like this:

a < b < c

Are interpreted as:

a < b and b < c

Python evaluates it in logical order, but more efficiently (only once per variable).

13. Swap variables without temp

In many programming languages, swapping two variables requires a temporary third variable:

temp = a 
a = b 
b = temp

But in Python, there’s a cleaner, faster, and more Pythonic way:

a, b = b, a

Why It Works

Python supports tuple unpacking, which lets you assign multiple variables in a single line.

Here’s what happens:

a, b = b, a
  1. The right-hand side creates a tuple: (b, a)
  2. Python unpacks that tuple into the variables on the left: a, b

Example:

x = 5 
y = 10 
 
x, y = y, x 
 
print(x)  # 10 
print(y)  # 5

Imagine you’re sorting a list manually:

if numbers[i] > numbers[i + 1]: 
    numbers[i], numbers[i + 1] = numbers[i + 1], numbers[i]

Perfect for quick swaps — no temp clutter needed.

14. Use defaultdict from collections

A defaultdict is a special type of dictionary from Python’s collections module that automatically assigns default values to keys that don’t exist — so you don’t get a KeyError.

The Problem With Normal Dicts

Let’s say you’re trying to count the number of times each letter appears in a string:

text = "banana" 
letter_counts = {} 
 
for char in text: 
    if char in letter_counts: 
        letter_counts[char] += 1 
    else: 
        letter_counts[char] = 1

This works — but it’s verbose.

The defaultdict Way

Here’s the cleaner, Pythonic version using defaultdict:

from collections import defaultdict 
 
text = "banana" 
letter_counts = defaultdict(int) 
 
for char in text: 
    letter_counts[char] += 1 
 
print(letter_counts)

Boom! No need to check if the key exists. defaultdict automatically initializes it to 0 (in this case) when missing.

How It Works

When you create a defaultdict, you pass in a factory function — like int, list, set, etc.

Here’s what each does:

defaultdict(int) → missing keys start with 0
defaultdict(list) → missing keys start as empty []
defaultdict(set) → missing keys start as empty set()

Example with list:

from collections import defaultdict 
 
grouped = defaultdict(list) 
 
data = [ 
    ("fruit", "apple"), 
    ("fruit", "banana"), 
    ("vegetable", "carrot") 
] 
 
for category, item in data: 
    grouped[category].append(item) 
 
print(grouped)

Output

defaultdict(<class 'list'>, { 
  'fruit': ['apple', 'banana'], 
  'vegetable': ['carrot'] 
})
defaultdict avoids KeyError by providing automatic default values.
Cleaner and faster than manual if...else or dict.get().
Super useful for counting, grouping, and initializing collections.

15. Use Counter for frequency

Counter is a powerful class from Python’s collections module designed specifically to count the frequency of elements in an iterable — like a list, string, or tuple.

It’s like a dictionary, but optimized for counting.

The Old Way

Let’s say you want to count how many times each letter appears in a word:

word = "mississippi" 
counts = {} 
 
for letter in word: 
    if letter in counts: 
        counts[letter] += 1 
    else: 
        counts[letter] = 1 
 
print(counts)

It works, but it’s verbose and repetitive.

The Counter Way (Pythonic)

from collections import Counter 
 
word = "mississippi" 
counts = Counter(word) 
 
print(counts) 
 
# output : Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})

Much cleaner — and faster too!

Why It’s Powerful

The Counter object is more than a simple dictionary. It comes with super useful methods:

1. .most_common(n)

Returns the top n most common elements:

counts.most_common(2) 
# [('i', 4), ('s', 4)]

2. Arithmetic Operations

You can add, subtract, and intersect multiple Counters:

c1 = Counter("apple") 
c2 = Counter("plea") 
 
print(c1 + c2)        # Adds frequencies 
print(c1 - c2)        # Subtracts frequencies 
print(c1 & c2)        # Intersection (min of counts) 
print(c1 | c2)        # Union (max of counts)
Counter simplifies frequency counting to one line.
Comes with useful methods like .most_common().
Supports arithmetic and set-like operations.
Great for strings, lists, logs, and analytics.

16. Sort with custom keys

Python’s built-in sorted() function and .sort() method allow you to sort complex data — not just numbers or strings — using a custom rule, via the key= parameter.

Think of it as telling Python:
“Hey, sort this based on this logic instead of default comparison.”

The Basics

numbers = [5, 2, 9, 1] 
sorted_numbers = sorted(numbers)  # Default sort: ascending

But what if you want to sort…

A list of strings by their length?
A list of dictionaries by a specific key?
A list of tuples by the second item?

That’s where key= comes in.

Example 1: Sort strings by length

words = ["banana", "pie", "Washington", "to"] 
sorted_words = sorted(words, key=len) 
print(sorted_words) 
 
# output : ['to', 'pie', 'banana', 'Washington']

Here, key=len tells Python to use the length of each word for sorting.

Example 2: Sort tuples by the second item

pairs = [(1, 3), (2, 2), (4, 1)] 
sorted_pairs = sorted(pairs, key=lambda x: x[1]) 
print(sorted_pairs) 
 
# output : [(4, 1), (2, 2), (1, 3)]

You’re saying:
“Sort each tuple by its second element (x[1])."

Example 3: Sort dictionaries by a value

students = [ 
    {"name": "Alice", "score": 85}, 
    {"name": "Bob", "score": 92}, 
    {"name": "Charlie", "score": 78} 
] 
 
sorted_students = sorted(students, key=lambda s: s["score"], reverse=True)

Output (sorted by score descending):

[ 
    {'name': 'Bob', 'score': 92}, 
    {'name': 'Alice', 'score': 85}, 
    {'name': 'Charlie', 'score': 78} 
]

Use operator module

If you’re sorting by dict key or object attribute, use itemgetter or attrgetter for cleaner code.

from operator import itemgetter 
 
sorted_students = sorted(students, key=itemgetter("score"))

17. Use Pathlib over os.path

pathlib is a modern, object-oriented library in Python for working with filesystem paths. It was introduced in Python 3.4 and is designed to replace the older os.path module, which uses string manipulation to work with file and directory paths.

With pathlib, paths are no longer plain strings — they become Path objects, which makes your code cleaner, more readable, and cross-platform.

The Old Way (Using os.path)

import os 
 
path = "/home/user" 
file = "notes.txt" 
 
full_path = os.path.join(path, file) 
 
if os.path.exists(full_path): 
    print("File exists!")

It works, but it’s string-heavy and not very intuitive.

The Modern Way (Using pathlib)

from pathlib import Path 
 
path = Path("/home/user") / "notes.txt" 
 
if path.exists(): 
    print("File exists!")

Notice how much cleaner and natural the syntax is?
You’re chaining paths using the / operator instead of os.path.join().

Common pathlib Examples

Create a path

p = Path("folder") / "subfolder" / "file.txt"

Check if file exists

if p.exists(): 
    print("It exists")

Create directories

p.mkdir(parents=True, exist_ok=True)

Read a file

content = p.read_text()

Write to a file

p.write_text("Hello, world!")

Iterate through a directory

for file in Path("my_folder").iterdir(): 
    print(file.name)

Find all .txt files recursively

for file in Path("docs").rglob("*.txt"): 
    print(file)

18. Use context managers for safety

A context manager in Python is a construct that properly manages resources — like files, network connections, database sessions, or locks — ensuring they’re cleaned up properly (even if errors occur).

You usually use them via the with statement.

The Problem Without Context Managers

Let’s say you open a file like this:

file = open("data.txt", "r") 
data = file.read() 
file.close()

Looks fine, but what if an error happens between open() and close()?

The file may never get closed, causing memory leaks, locked files, or other issues.

The Context Manager Way

with open("data.txt", "r") as file: 
    data = file.read()

Once the code inside the with block finishes (or errors out), Python automatically closes the file for you.

It’s cleaner, safer, and more Pythonic.

More Real-World Examples

Working with Files

with open("log.txt", "w") as f: 
    f.write("Logging started")

Using Threading Locks

from threading import Lock 
 
lock = Lock() 
 
with lock: 
    # safely access shared resource 
    do_something()

Temporary Files

import tempfile 
 
with tempfile.TemporaryFile() as tmp: 
    tmp.write(b"Hello")

SQLite Database Connections

import sqlite3 
 
with sqlite3.connect("mydb.db") as conn: 
    cursor = conn.cursor() 
    cursor.execute("SELECT * FROM users")

The connection will automatically commit and close when the block exits.

You Can Make Your Own Context Managers Too

Using the contextlib module:

from contextlib import contextmanager 
 
@contextmanager 
def open_file(path): 
    f = open(path, 'r') 
    try: 
        yield f 
    finally: 
        f.close() 
 
# Now use it like: 
with open_file("data.txt") as f: 
    print(f.read())

19. Use type hints for clarity

Type hints (also called type annotations) were introduced in Python 3.5 via PEP 484.
They allow you to explicitly specify the expected data types of variables, function arguments, and return values — but without enforcing them at runtime.

They’re mainly for:

Clarity
IDE support
Better collaboration
Static type checking (using tools like mypy or pyright)

Without Type Hints

def greet(name): 
    return "Hello, " + name

This works, but it’s unclear what type name should be — a string? number?

With Type Hints

def greet(name: str) -> str: 
    return "Hello, " + name

Now it’s obvious:

name should be a string
The function returns a string

This improves readability and helps catch bugs before runtime.

IDEs Love Type Hints

When you use hints, tools like VSCode, PyCharm, or even Copilot can:

Autocomplete arguments
Warn about type mismatches
Suggest better code

More Examples

Function with multiple argument types:

def add(x: int, y: int) -> int: 
    return x + y

Optional / Default values:

from typing import Optional 
 
def get_user(id: int) -> Optional[str]: 
    # returns None or a username 
    ...

Lists and Dictionaries:

from typing import List, Dict 
 
def square_all(numbers: List[int]) -> List[int]: 
    return [n ** 2 for n in numbers] 
 
def get_usernames(users: Dict[int, str]) -> List[str]: 
    return list(users.values())

Custom Classes:

class User: 
    def __init__(self, name: str, age: int): 
        self.name = name 
        self.age = age

Static Type Checking with mypy

You can run:

mypy your_script.py

And get early warnings if types don’t match. It’s like a spellchecker for your logic.

20. Use __slots__ in classes to save memory

In Python, when you define a regular class, Python stores all instance attributes in a special dictionary called __dict__.

This allows you to dynamically add new attributes at runtime, but it uses extra memory.

By defining __slots__, you tell Python exactly which attributes your objects will have, and Python doesn’t create a __dict__ for each object — saving memory.

The Problem with Regular Classes

class Person: 
    def __init__(self, name, age): 
        self.name = name 
        self.age = age

Each Person object here has a __dict__, which stores its attributes like this:

person.__dict__  # {'name': 'Alice', 'age': 30}

This __dict__ is flexible, but takes extra space per object, especially if you're creating millions of instances (e.g., in data processing, simulations, etc.).

The __slots__ Solution

class Person: 
    __slots__ = ['name', 'age']  # only these two attributes allowed 
 
    def __init__(self, name, age): 
        self.name = name 
        self.age = age

Now:

No __dict__ is created.
Memory usage is reduced.
You can’t dynamically add new attributes (which is a good thing in many cases — it enforces structure).

Try this:

p = Person("Alice", 30) 
p.name  # works 
 
p.new_attr = "Oops"  # ❌ AttributeError: 'Person' object has no attribute 'new_attr'

When Should You Use __slots__?

Use it when:

You have many instances of a class (think: thousands or millions).
Your objects don’t need dynamic attributes.
You care about memory optimization and speed.

How Much Memory Does It Save?

Let’s compare:

import sys 
 
class Regular: 
    def __init__(self, x, y): 
        self.x = x 
        self.y = y 
 
class Slotted: 
    __slots__ = ['x', 'y'] 
    def __init__(self, x, y): 
        self.x = x 
        self.y = y 
 
r = Regular(1, 2) 
s = Slotted(1, 2) 
 
print(sys.getsizeof(r))  # might be ~56-64 bytes + overhead 
print(sys.getsizeof(s))  # usually smaller

The actual savings depend on your Python version and platform, but with many objects, the gain adds up.

21. Use dataclasses for clean models

Python’s dataclasses module (introduced in Python 3.7) provides a clean, concise way to write classes that are primarily used to store data — like models or records — without all the boilerplate code.

Instead of manually writing __init__, __repr__, __eq__, and other methods, you just use a decorator: @dataclass.

Traditional (Verbose) Way

Let’s say you want a simple Book class:

class Book: 
    def __init__(self, title, author, price): 
        self.title = title 
        self.author = author 
        self.price = price 
 
    def __repr__(self): 
        return f"Book(title={self.title}, author={self.author}, price={self.price})" 
 
    def __eq__(self, other): 
        return (self.title == other.title and 
                self.author == other.author and 
                self.price == other.price)

That’s a lot of code just for a container of data.

Now With @dataclass

from dataclasses import dataclass 
 
@dataclass 
class Book: 
    title: str 
    author: str 
    price: float

That’s it.

Python automatically gives you:

__init__
__repr__
__eq__
Optionally: ordering, immutability, defaults, and more

Usage Example

book1 = Book("Python 101", "Aashish Kumar", 9.99) 
book2 = Book("Python 101", "Aashish Kumar", 9.99) 
 
print(book1)         # Book(title='Python 101', author='Aashish Kumar', price=9.99) 
print(book1 == book2)  # True

You get readable output and comparison for free!

22. Inline if statements

An inline if statement (also called a ternary operator) lets you write short, one-line conditional expressions.

Instead of using a full if...else block, you can write the logic in a single, readable line.

Traditional if...else:

age = 20 
 
if age >= 18: 
    status = "Adult" 
else: 
    status = "Minor"

Inline if Version:

status = "Adult" if age >= 18 else "Minor"

Same logic — but way cleaner for simple conditions.

Real-World Examples:

Set a label based on a score:

label = "Pass" if score >= 40 else "Fail"

Choose an icon based on a flag:

icon = "🔒" if is_private else "🌐"

Format output:

print(f"Hello, {name if name else 'Guest'}")

Be Careful

Avoid overusing inline if for complex logic — it can hurt readability.

Bad:

result = "win" if score > 80 else "average" if score > 50 else "lose"

Better:
Use a regular if...elif...else for clarity.

23. Use try/except smartly — not excessively

In Python, try/except blocks are used to handle errors gracefully. But overusing them or using them the wrong way can hide bugs and make your code harder to understand and debug.

The trick is to use try/except intelligently — only where it’s necessary and expected, not to wrap huge blocks or ignore errors silently.

Smart try/except Usage

Let’s say you’re converting user input to a number:

try: 
    age = int(user_input) 
except ValueError: 
    print("Please enter a valid number.")

This is smart:

You expect that input might not be a number.
You’re only catching the specific error (ValueError).
You handle it cleanly.

Bad Example — Too Broad:

try: 
    # A lot of things happening 
    do_this() 
    do_that() 
    maybe_fail() 
except: 
    pass  # silently ignoring ALL errors

Problems here:

Catches all exceptions, even serious ones like KeyboardInterrupt, MemoryError, or bugs in logic.
The error is hidden — which makes debugging a nightmare.
There’s no logging, fallback, or correction.

Smart Tips for Using try/except

1. Catch only what you need

Don’t use a bare except: — it swallows everything.

try: 
    result = 10 / x 
except ZeroDivisionError: 
    result = 0

2. Keep it short

Wrap only the specific line that might raise an error:

# Bad  
try: 
    # this line doesn't raise 
    print("Hello") 
    risky_function()  # only this can fail 
except SomeError: 
    print("Something went wrong") 
 
# Good 
print("Hello") 
try: 
    risky_function() 
except SomeError: 
    print("Something went wrong")

3. Avoid using it for flow control

Some developers do this (don’t):

try: 
    my_dict["key"] 
except KeyError: 
    my_dict["key"] = "default"

Instead, use:

my_dict.setdefault("key", "default")

Or:

if "key" not in my_dict: 
    my_dict["key"] = "default"

Exceptions are for exceptional situations, not for regular logic.

4. Use else and finally if needed

try: 
    file = open("data.txt") 
except FileNotFoundError: 
    print("File not found.") 
else: 
    data = file.read() 
    print(data) 
finally: 
    file.close()
else: runs only if no exception occurred
finally: runs no matter what (great for cleanup)

24. Combine multiple strings with .join()

Instead of combining strings with + or using loops, Python gives you a fast, readable, and efficient way to concatenate strings using:

"separator".join(iterable)

Example:

words = ["Python", "is", "awesome"] 
sentence = " ".join(words) 
print(sentence)  # Output: Python is awesome

Why .join() is Better than +

Let’s say you try this:

sentence = "" 
for word in words: 
    sentence += word + " "

This works — but it’s inefficient, especially with long lists, because each + creates a new string in memory. Strings in Python are immutable, so every time you add one, Python builds a brand new one.

Using .join() is faster and more memory-efficient, because it allocates the memory just once.

How .join() Works

It takes an iterable (like a list or tuple)
Places the string (the one you call .join() on) between each element

More Examples:

csv = ",".join(["apple", "banana", "cherry"]) 
print(csv)  # apple,banana,cherry 
 
path = "/".join(["home", "user", "documents"]) 
print(path)  # home/user/documents 
 
hyphenated = "-".join(["2025", "08", "07"]) 
print(hyphenated)  # 2025-08-07

Bonus: Avoid This Common Mistake

Some try to call .join() like this:

words.join(" ")  # ❌ This will throw an error

Correct syntax:

" ".join(words)  # ✅

The string you’re using as a separator (like " " or "-") must come before .join() — and the list goes inside the parentheses.

Use .join() whenever you're combining strings in a loop or list. It’s the Pythonic way — and it’s faster, cleaner, and safer.

25. Use repr() for debug-friendly strings

repr() stands for representation. It returns a string that shows how an object can be recreated, or gives a detailed internal view of the object — ideal for debugging.

Compare it to str():

str() is meant for human-friendly output.
repr() is meant for developer-friendly output.

Quick Example:

name = "Aashish" 
print(str(name))   # Aashish 
print(repr(name))  # 'Aashish'

See how repr() includes the quotes? That’s helpful to see exact formatting, escape characters, or trailing spaces.

When Should You Use repr()?

  • Debugging: See raw or unambiguous values (e.g., strings with newline characters or whitespace)
  • Logging: Want to see values as they exist in code.
  • Creating string representations of objects for developers (via __repr__() method in classes).

Real-World Example

text = "Hello\nWorld" 
print(str(text))   # Hello 
                  # World 
 
print(repr(text))  # 'Hello\nWorld'

With repr(), you clearly see the \n newline character — very helpful when debugging weird string bugs.

Bonus: Custom __repr__() in Your Own Classes

If you create custom classes, you can define how they appear when printed using repr():

class User: 
    def __init__(self, name): 
        self.name = name 
 
    def __repr__(self): 
        return f"User(name={repr(self.name)})" 
 
user = User("Aashish") 
print(user)  # User(name='Aashish')

This is much better than the default <__main__.User object at 0xABC123>.

Use repr() when logging or debugging anything suspicious — like:

print(f"User input: {repr(user_input)}")

This way, you’ll spot unwanted \n, \t, spaces, etc.

26. Use isinstance() over type()

When checking if a variable is a certain type, Python gives you two options:

  1. type(x) == Something
  2. isinstance(x, Something) (recommended)

Though both seem to work, isinstance() is more powerful, flexible, and Pythonic.

Why isinstance() is Better

Here’s a side-by-side comparison:

x = 5 
 
# Using type 
if type(x) == int: 
    print("x is an integer") 
 
# Using isinstance 
if isinstance(x, int): 
    print("x is an integer")

Both work. So what’s the difference?

Let’s say you’re working with inheritance:

class Animal: 
    pass 
 
class Dog(Animal): 
    pass 
 
d = Dog()

Now check:

type(d) == Animal      # ❌ False 
isinstance(d, Animal)  # ✅ True

isinstance() checks if the object is an instance of a class or its subclasses.

type() checks for exact type match only — it does not consider inheritance.

Real-World Use Case

Let’s say you’re writing a function that can accept either an integer or a float:

def add_ten(x): 
    if isinstance(x, (int, float)): 
        return x + 10 
    raise TypeError("x must be a number")

Here, you’re checking for multiple types — which type() can’t do directly. But isinstance() makes it simple.

If you ever need to check if a variable is any of several types, just pass a tuple:

isinstance(x, (int, float, complex))

Use isinstance() instead of type()
 — especially when dealing with inheritance, polymorphism, or multiple types. It’s cleaner, safer, and just more Pythonic.

27. Use itertools for advanced iteration

itertools is a standard library module in Python that provides a collection of fast, memory-efficient tools for working with iterators.

It’s like a Swiss Army knife for looping — letting you chain, group, filter, and repeat iterables in ways that are both powerful and elegant.

Why Use itertools?

Handles large datasets without memory bloat
Offers lazy evaluation (computes on-the-fly)
Provides cleaner, more readable code
Ideal for complex looping patterns like combinations, groupings, or infinite sequences

Most Useful itertools Functions

Let’s look at some practical examples you’ll actually use.


1. itertools.count(start=0, step=1)

Creates an infinite counter — useful for indexing or generating IDs.

from itertools import count 
 
for i in count(10, 2): 
    print(i) 
    if i > 20: 
        break

Output:

10 
12 
14 
16 
18 
20 
22

2. itertools.cycle(iterable)

Endless loop over an iterable — great for round-robin scheduling or repeating behavior.

from itertools import cycle 
 
colors = ['red', 'green', 'blue'] 
cycler = cycle(colors) 
 
for _ in range(5): 
    print(next(cycler))

Output:

red 
green 
blue 
red 
green

3. itertools.chain(*iterables)

Flattens multiple iterables into a single stream.

from itertools import chain 
 
a = [1, 2, 3] 
b = [4, 5] 
print(list(chain(a, b)))

Output:

[1, 2, 3, 4, 5]

Great when merging multiple lists without nesting.

4. itertools.combinations(iterable, r)

All possible r-length combinations (no repeats).

from itertools import combinations 
 
items = ['a', 'b', 'c'] 
print(list(combinations(items, 2)))

Output:

[('a', 'b'), ('a', 'c'), ('b', 'c')]

Perfect for generating test cases or solving algorithm challenges.

5. itertools.groupby(iterable, key=...)

Groups adjacent items with the same key — commonly used with sorted data.

from itertools import groupby 
 
data = ['a', 'a', 'b', 'b', 'c', 'a'] 
for key, group in groupby(data): 
    print(key, list(group))

Output:

a ['a', 'a'] 
b ['b', 'b'] 
c ['c'] 
a ['a']
Note: Works only on consecutive groups — so data often needs to be pre-sorted.

Advanced Tip: Combine itertools with map(), filter(), or generators for powerful data pipelines.

Example:

from itertools import islice 
 
# Infinite counter, sliced down to just 5 items 
print(list(islice(count(100, 5), 5))) 
 
 
# output : [100, 105, 110, 115, 120]

Use itertools when your loop logic starts to feel clunky — it can save you lines of code and tons of memory.

28. Chain filters and maps

Instead of writing multiple for-loops or cluttered list comprehensions, you can chain Python’s built-in filter() and map() functions to build powerful, readable, and memory-efficient pipelines.

This trick is especially useful when dealing with data cleaning, transformation, or extraction.

Let’s Understand the Two:

filter(function, iterable) : Filters out elements where the function returns False.
map(function, iterable) : Applies a function to each element in an iterable and returns a new iterable.

Chaining Them — Real Example

Goal: From a list of numbers, filter out odd numbers and then square the remaining even numbers.

numbers = [1, 2, 3, 4, 5, 6] 
 
result = map(lambda x: x**2, filter(lambda x: x % 2 == 0, numbers)) 
print(list(result)) 
 
# output : [4, 16, 36]

What’s Happening Here?

filter(lambda x: x % 2 == 0, numbers) keeps only even numbers → [2, 4, 6]
map(lambda x: x**2, ...) squares each → [4, 16, 36]

Chaining filter() and map() makes your code:

More expressive
Easier to test
More efficient

And Python is all about writing elegant, powerful one-liners that still remain readable.

29. Use timeit to benchmark code

Python’s built-in timeit module is the most accurate way to measure the execution time of small bits of code.

It’s far better than using time.time() because it:

Automatically runs your code multiple times for better accuracy
Minimizes the effect of background processes or caching
Is cross-platform consistent for benchmarking

Simple Example: Comparing Two Ways to Create a List

Let’s compare two ways to create a list of numbers from 0 to 999:

# Method 1: List comprehension 
timeit.timeit('[x for x in range(1000)]', number=1000) 
 
# Method 2: Using list() and range() 
timeit.timeit('list(range(1000))', number=1000)

This runs each snippet 1,000 times and tells you how long it took.

Full Example with timeit

import timeit 
 
stmt1 = '[x for x in range(1000)]' 
stmt2 = 'list(range(1000))' 
 
time1 = timeit.timeit(stmt1, number=1000) 
time2 = timeit.timeit(stmt2, number=1000) 
 
print(f"List comprehension: {time1:.4f} sec") 
print(f"list(range()):     {time2:.4f} sec")

Output (will vary per system):

List comprehension: 0.0524 sec   
list(range()):     0.0289 sec

This tells you that list(range()) is slightly faster in this case.

Why Not Use time.time() Instead?

import time 
start = time.time() 
# do stuff 
end = time.time() 
print(end - start)

This method:

Is OK for long-running tasks
Is less accurate for small functions or fast code
Can be affected by system clock updates or lag

Use Case: Optimize Code with Real Data

Say you’re optimizing a function:

def slow_way(): 
    return sorted(set([x for x in range(10000)])) 
 
def fast_way(): 
    return list(dict.fromkeys(range(10000)))

Use timeit to compare:

import timeit 
 
print(timeit.timeit('slow_way()', globals=globals(), number=100)) 
print(timeit.timeit('fast_way()', globals=globals(), number=100))

If you’re serious about writing performant Python code, you should be using timeit — it helps you understand what’s truly faster, not just what looks cleaner.

30. Use dir() and help() often to explore

Python is known for its introspective capabilities — meaning, you can examine objects, modules, or even built-in types at runtime to discover what they can do. Two built-in functions that help with this are:

dir() → Lists all attributes and methods of an object
help() → Provides a human-readable description/documentation

These are especially helpful when:

You’re working with new libraries or unfamiliar objects
You want to quickly discover methods without Googling
You’re in a REPL or using Jupyter Notebook/IPython

dir() – See What’s Inside

Think of dir() as a way to peek under the hood.

x = [1, 2, 3] 
print(dir(x))

Output:

['__add__', '__class__', ..., 'append', 'clear', 'copy', 'count', 'extend', ...]

You’ll see everything that the list object x can do — methods like .append(), .clear(), .copy(), etc.

You can also run:

import math 
print(dir(math))

To list all functions and constants in the math module.

help() – Get Documentation Instantly

If dir() is a map, help() is the guidebook.

Example:

help(list.append)

Output:

Help on method_descriptor: 
 
append(self, object, /) 
    Append object to the end of the list.

Boom — now you know what it does and how to use it. No Stack Overflow required.

You can also use help() on entire modules:

import math 
help(math)

Or on your own functions:

def greet(name): 
    """Greets the person by name.""" 
    print(f"Hello, {name}!") 
 
help(greet)

Python will read the docstring and show you what your function does. This is great when collaborating or revisiting your own code later.

The best Python developers explore constantly. Using dir() and help() often will level up your understanding and help you become self-sufficient, curious, and faster at solving problems.


Final Thoughts: The Difference Is in the Details

Mastering Python isn’t about memorizing every method in the standard library. It’s about writing better code — cleaner, faster, more elegant solutions that save you time and reduce bugs.

These 30 tips won’t just make your code shorter — they’ll change the way you think in Python.

Pick 3–5 and start using them today. Bookmark this list. Return when you’re ready to level up again.


Keep leveling up. The Pythonic way is the smart way.

Photo by Brayden Prato on Unsplash