Python is a high-level, dynamically typed, interpreted language designed for readability, developer velocity, and cross-paradigm flexibility. Python prioritizes simplicity, readability, and developer experience over raw performance.

Python became popular because it let you write things fast: AI/ML (NumPy, PyTorch, TensorFlow), Web backends (FastAPI, Django, Flask), Automation & scripting, Data Science (Pandas, SciKit-Learn, Matplotlib), and for teaching beginner developers.

Key Language Features

Python is multi-paradigmatic supporting OOP, functional programming, and procedural programming.

  • Dynamic Typing: No type declarations needed.
  • Interpreted: No compilation step. Just run and go.
  • Everything is an object: Literally, including functions and modules.
  • First-class functions: supports closures, lambdas, and decorators.
  • Indentation-based blocks: No {}—just colons and indentation.

Typing and Modern Python

Since Python 3.5+, optional type hints have enabled gradual typing.

def greet(name: str) -> str:
    return f"Hello, {name}"
  • Use mypy, pydantic, or Pyright to enforce type contracts.

The “dunder” API

Behind every intuitive behavior is a magic method (double underscore methods like __init__, __str__, __getitem__, __call__, etc.)

This API makes Python incredibly flexible for metaprogramming and DSLs.

Example: making a class iterable:

class MyRange:
    def __init__(self, end): self.i, self.end = 0, end
    def __iter__(self): return self
    def __next__(self):
        if self.i >= self.end: raise StopIteration
        val = self.i
        self.i += 1
        return val

Tooling and Ecosystem

  • venv: Isolated environments.
  • pip: Package management.
  • black, isort, ruff: Linting and formatting.
  • pytest, hypothesis: Testing
  • jupyter: Interactive data workflows.
  • poetry, pipenv: Modern dependency + env management.
  • pydantic: Declarative data validation.
  • asyncio, httpx, uvicorn: Async web stack.

Potential Gotchas for Experienced Devs

  • Indentation quirks: Mixing tabs and spaces can be painful.
  • Truthiness: [], 0, None, '', {} are all false.
  • Lack of visibility: No private/public keywords. Use _/__ conventions.
  • Python vs 3 was a decade-long mess. Today use Python 3.12+.

Important Data Types

Strings

f-strings: string interpolation, inline expressions, & number formatting

name = "Alex"
f"Hello, {name}!"  # → "Hello, Alex!"
 
f"2 + 2 = {2 + 2}"  # → "2 + 2 = 4"
 
pi = 3.14159
f"{pi:.2f}"  # → "3.14"

Lists

List functions: length, slicing, sorting
my_list = [4, 3, 2, 1]
len(my_list)  # 4
my_list[1:2]  # [3, 2]
my_list.sort()  # [1, 2, 3, 4]
Manipulating lists: append, insert, remove, pop
my_list = [1, 2, 3]
my_list.append(9)  # [1, 2, 3, 9]
my_list.insert(2, 7)  # [1, 2, 7, 3, 9]
my_list.remove(7)  # [1, 2, 3, 9]
my_list.pop(0)  # [2, 3, 9]

Sets

Sets are useful for a de-duplicated list.

my_set = {1, 2, 2, 2, 3, 4}
print(my_set)  # {1, 2, 3 ,4}
 
for x in my_set:
	print(x)      # sets don't guarantee order, will likely print out-of-order
 
my_set.discard(4)  # {1, 2, 3}
my_set.add(9)  # {1, 2, 3, 9}
my_set.update([7, 8])  # {1, 2, 3, 9, 7, 8}

Tuples

Tuples are immutable lists.

my_tuple = (1, 2, 3) 
print(my_tuple)  #(1, 2, 3)
print(my_tuple[1])  # 2

Dictionaries

user_dictionary = {
	'name': 'Alexa'
	'age': 32
}
 
print(user_dictionary)  # {'name': 'Alexa', 'age': 32}
 
user_dictionary.get("name")  # Alexa
user_dictionary["location"] = 'NYC'
user_dictionary.pop("age")  # removes age
user_dictionary.clear()  # {}
del user_dictionary  # completely deletes the dictionary

Functions

def print_name(name):
	print(f"Hello {name}")
 
print_name("Alexa")  # Hello Alexa

Packages and Imports

  • Import is the act of bringing code into your file from another module or package.
  • A module is any .py file.
  • A package is a folder that contains an __init.py__ file. It can contain multiple modules or sub-packages.
my_project/
└── utils/ (package)
    ├── __init__.py
    └── helpers.py (module)

In Python, you use import to bring in code from another module or package—whether it’s built-in, third-party, or your own.

import module

Importing an entire module adds only the module name (e.g. math) to your namespace. You must access its functions or constants using the module prefix. This is recommended for its clarity and explicit origin of very symbol, reducing naming collisions and encourages modular thinking.

import math
math.sqrt(16)
math.pi
from module import symbol

Imports specific symbols from a module. This makes a module prefix unnecessary and improves concision. However, this can lead to name collisions if overused. This is most ideal for common utility functions (from os.path import join) or frequently used classes (from datetime import datetime).

from math import sqrt
sqrt(16)
from module as alias

Aliases import the the whole module. Use aliases only when the alias is well-known! (e.g. np, pd)

import numpy as np
np.array([1,2,3])
from .module import name and from module import *

Avoid these, especially the wildcard import. These lack safety and readability. Relative imports can be useful for internal packages where avoiding circular imports is necessary. Wildcard imports import everything from a module into your local namespace.

# Relative imports
from .helpers import foo
from ..core.utils import bar
from math import *  # Wildcard imports are bad practice!

Objects

Everything is an object in Python— even literals like 42, “hello” and functions and modules.

class Dog:
    def __init__(self, name):
	    # instance variables
        self.name = name
        self.__age = age  # double underscore means "private"
 
    def bark(self):
        return f"{self.name} says woof!"
	
    @property  # getter for age
    def age(self):
        return self.__age
 
    @age.setter  # property: setter for age
    def age(self, value):
        if value < 0:
            raise ValueError("Age must be non-negative")
        self.__age = value
 
    # dunder method for string representation
    def __str__(self):
        return f"{self.name} is {self.age()} years old"
fido = Dog("Fido", 3)
print(fido.bark())       # "Fido says woof!"
print(fido.age)          # 3 (accesses via @property)
fido.age = 4             # setter
print(fido)              # "Fido is 4 years old"

Inheritance & Method Override

class Animal:
	def __init__(self, name):
		self.name = name
 
    def utter(self):
	    return f"{self.name} makes a sound"
class Cat(Animal):
	def __init__(self, name, breed):
		super().__init__(name)
		self.breed = breed
 
    def utter(self):
	    return f"{self.name} says meow"

Access Control in Python (Encapsulation)

While languages like Swift have explicit access control modifiers (public, internal private), Python uses naming conventions only. This means programmers can call anything and are trusted to follow convention.

PrefixMeaning
fooPublic—accessible anywhere. e.g. `x.publicVa
_fooInternal—accessible but don’t call externally. e.g. `x._internalV
__foo Private—name-mangled. Cannot call x.__privateVar but still accessible as self__.privateVar hin

Special “Dunder” Methods

These special methods let you customize object behavior and hook into Python’s syntax.

class Vector:
    def __init__(self, x): self.x = x
    def __add__(self, other): return Vector(self.x + other.x)
    def __repr__(self): return f"Vector({self.x})"
v1 = Vector(1)
v2 = Vector(2)
v1 + v2   # Vector(3)
MethodPurpose
__init__Constructor
__repr__Debug string
__len__len(obj)
__getitem__obj[key]
__setitem__obj[key] = val
__eq__Equality check (==)
etc…

@dataclass

If you just need a container for related data (like a Swift struct), Python gives you two idiomatic options:

  1. Classic class
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
  1. @dataclass (Python 3.7+)
  • Auto-generates __init__, __repr__, __eq__, etc.
  • Great for Plain Old Python Objects without all the boilerplate and slightly better behavior
from dataclasses import dataclass
 
@dataclass
class User:
    name: str
    age: int

Duck (Structural) Typing & Interfaces

In Python, what matters is what an object can do, not what it says it can do.

  • There’s no interface keyword, no need to conform to a named protocol.
  • If an object has the right method(s), you can use it.
class User:
    def to_json(self): return '{"type": "user"}'
 
class Product:
    def to_json(self): return '{"type": "product"}'
 
def serialize(obj):
    return obj.to_json()  # Both User & Product work, no interface required

In Python 3.8+, you can write interface-like constructs if you want IDE/linter support. This doesn’t affect runtime behavior and you don’t explicitly need to implement it, just have the methods.

from typing import Protocol
 
class JsonSerializable(Protocol):
    def to_json(self) -> str: ...
 
def serialize(obj: JsonSerializable): ...