💻
Discipline

Programming

A practical reference for core programming concepts in Python, JavaScript, and SQL — from built-in types and control flow to higher-order functions, object-oriented patterns, error handling, async programming, and data processing.

7 sections Python · JS · SQL 25+ concepts

Built-in Types

Every language ships with a set of fundamental data types — the building blocks you use before importing any library. Python and JavaScript share many concepts but differ in naming and behaviour.

Overview

The core types that exist natively in Python and JavaScript without any imports.

Numeric Types

Types for representing numbers — integers (whole numbers), floats (decimals), and complex numbers where supported. Python distinguishes int from float explicitly; JavaScript uses a single number type for both.

In Plain Terms

Think of int as a whole pie and float as a pie slice — one is exact and countable, the other may have imprecise edges due to floating-point arithmetic. Always be aware of which you're working with when doing math.

# Python numeric types
x = 42          # int
y = 3.14         # float
z = 2 + 3j       # complex
type(x)          # <class 'int'>
isinstance(y, float)  # True
int(3.9)        # 3  (truncates, not rounds)

Key takeaway: Python's int has arbitrary precision; JavaScript's number is always IEEE 754 double — use BigInt in JS for large integers.

Sequence Types

Ordered collections of items. Python offers list (mutable), tuple (immutable), and range. JavaScript's Array covers both mutable list and tuple roles.

In Plain Terms

A list is a shopping cart you can keep adding to or rearranging. A tuple is the printed receipt — same order, fixed forever. Use tuples to signal "this data should not change".

# Python sequence types
lst  = [1, 2, 3]       # list — mutable
tup  = (1, 2, 3)       # tuple — immutable
rng  = range(0, 10, 2) # range — lazy sequence

lst[0] = 99            # OK
# tup[0] = 99          # TypeError!

lst + tup               # TypeError — types must match for +
list(rng)              # [0, 2, 4, 6, 8]

Key takeaway: Prefer tuples over lists when data is conceptually fixed — it documents intent and can improve performance.

Mapping & Set Types

Key-value stores (dict in Python, plain Object or Map in JS) and unordered unique-value collections (set / Set).

In Plain Terms

A dict is like a phone book — look up a name (key), get a number (value). A set is like a bag of unique coins — order doesn't matter, duplicates are automatically removed.

# Python mapping and set
d = {'a': 1, 'b': 2}
d['c'] = 3          # add key
d.get('z', 0)      # 0  (safe lookup with default)

s = {1, 2, 2, 3}    # {1, 2, 3} — duplicates removed
s.add(4)
s & {2, 4, 6}       # {2, 4}  (intersection)

Key takeaway: Dictionary lookup is O(1) thanks to hash tables — always prefer dict over a list of tuples when you need key-based access.

Python vs JavaScript — Type Names

The same concept often has a different name across languages. This table maps Python built-in types to their nearest JavaScript equivalents.

Concept Python JavaScript Mutable?
Integer int number / BigInt No
Decimal float number No
Text str string No
Boolean bool boolean No
Ordered list list Array Yes
Immutable list tuple Object.freeze([]) No
Key-value map dict Object / Map Yes
Unique values set Set Yes
Nothing / null None null / undefined

Key takeaway: JavaScript has two "nothing" values (null = explicitly empty, undefined = never set) — Python's single None is simpler and less error-prone.

Flow Control

Flow control determines which code runs, when, and how many times. Mastering conditionals and loops is the foundation of every algorithm.

Condition

Branching execution based on whether an expression is truthy or falsy.

Conditional Statements

The if / elif / else chain (Python) or if / else if / else (JavaScript) tests conditions in order and executes the first matching branch.

In Plain Terms

Like a sorting machine at a post office: check if the parcel is a letter — if yes, put it here; else if it's a small box — there; else send it to oversize. Only one slot is ever chosen.

# Convert any type to float — Python
if type(x) in [int, float, str, bool]:
    y = float(x)
elif type(x) == list:   # x = [1, 2, 3]
    y = float(x[0])         # y = 1.0
elif type(x) == set:    # x = {1, 2, 3}
    y = float(x.pop())     # y = 1.0
elif type(x) == dict:   # x = {'a': 1, 'b': 2}
    y = float(list(x.values())[0])
else:
    y = None

Key takeaway: Python uses elif (not else if), relies on indentation instead of braces, and treats many values as falsy (None, 0, [], {}, "").

Loop

Repeating a block of code while a condition holds, with fine-grained control via break and continue.

While Loop

Keeps executing a block as long as its condition is True. Use break to exit early and continue to skip to the next iteration.

In Plain Terms

Like peeling an onion one layer at a time: keep going while there are layers left. If you hit a rotten spot you want to skip, continue — if you decide to stop entirely, break.

# Extract the first run of digits from a string — Python
x = 'abc123def456'
y = 0
while len(x) > 0:
    if (y > 0) and not x[0].isdigit():
        break                 # done collecting digits
    elif y == 0 and not x[0].isdigit():
        x = x[1:]
        continue              # skip leading non-digits
    y = 10 * y + int(x[0])
    x = x[1:]
# y == 123

Key takeaway: Prefer for ... in / for ... of when you know the iteration count; use while when termination depends on a dynamic condition.

For Loop & Iteration Helpers

The for loop iterates over any iterable. Python's enumerate() adds an index counter, zip() pairs two sequences, and range() generates integer sequences — all without materialising intermediate lists. JavaScript's for...of works on any iterable; for...in iterates over object keys.

In Plain Terms

enumerate is like a museum audio guide that tells you both which exhibit you're at (index) and what it is (value). zip is like pairing each left shoe with its right — it stops as soon as the shorter list runs out.

# for loop, enumerate, zip — Python
fruits = ['apple', 'banana', 'cherry']
prices = [1.2, 0.5, 2.0]

for fruit in fruits:
    print(fruit)           # apple, banana, cherry

for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")  # 1. apple, 2. banana...

for fruit, price in zip(fruits, prices):
    print(f"{fruit}: ${price}")  # apple: $1.2 ...

for n in range(0, 10, 2):   # 0, 2, 4, 6, 8
    print(n)

Key takeaway: In JavaScript, prefer for...of for arrays (iterates values) and for...in for objects (iterates keys) — mixing them up is a common source of bugs.

Comprehensions

Concise syntax for building new collections by transforming or filtering existing ones — replacing multi-line loops with a single readable expression.

List, Dict & Set Comprehensions

Python comprehensions produce a new collection in one expression: [expr for item in iterable if condition]. They are faster than equivalent for loops because the loop runs at C speed internally. JavaScript uses .map(), .filter(), and .reduce() for the same purpose.

In Plain Terms

Instead of: "make an empty list, loop over numbers, check if even, append to list" — just write: "give me all even numbers from this list." Comprehensions read like a sentence and are considered idiomatic Python.

# List comprehension
evens = [x for x in range(10) if x % 2 == 0]
# [0, 2, 4, 6, 8]

squares = [x**2 for x in range(5)]
# [0, 1, 4, 9, 16]

# Dict comprehension
word_lengths = {w: len(w) for w in ['cat', 'elephant', 'ox']}
# {'cat': 3, 'elephant': 8, 'ox': 2}

# Set comprehension (deduplicates automatically)
unique_mods = {x % 3 for x in range(9)}
# {0, 1, 2}

# Generator expression (lazy — no list built in memory)
total = sum(x**2 for x in range(1000000))

Key takeaway: Prefer comprehensions over map() + list() in Python — they're more readable and slightly faster. Use generator expressions (() instead of []) when you only need to iterate once and don't need the list in memory.

Functions

Functions are first-class citizens in Python and JavaScript — they can be passed as arguments, returned from other functions, and decorated to extend behaviour without modifying the original code.

Overview

Higher-order functions, decorators, closures, and recursive patterns.

Higher-Order Functions

A function that takes another function as an argument or returns a function as its result. This enables powerful patterns for building reusable, composable behaviour.

In Plain Terms

Think of it like a machine that takes a tool as input, applies the tool to some material, and hands back the finished product. The machine doesn't care which tool you pass in — it just knows how to use it.

# func_builder returns an inner function — Python
def mixer(string1, string2):
    if min(len(string1), len(string2)) == 0:
        return ''
    return string1[0] + string2[0] + mixer(string1[1:], string2[1:])

def func_builder(sample, *args, casefunc=str.upper, **kwargs):
    def dic_builder(x):
        x = casefunc(x)
        for arg in args:
            x = arg(x)
        return {kw: kwargs[kw](x) for kw in kwargs}
    return {'function': dic_builder, 'sample_result': dic_builder(sample)}

result = func_builder('hello',
                      lambda x: mixer(x, '12345'),
                      lstrip=str.lstrip,
                      rstrip=str.rstrip)
# result['sample_result'] == {'lstrip': 'H1E2L3L4O5 ',
#                              'rstrip': 'H1E2L3L4O5'}

Key takeaway: Functions as values unlocks functional patterns like map, filter, and reduce — always prefer these over manual loops when transforming collections.

Decorators

A decorator wraps a function to extend or modify its behaviour — written with @decorator_name syntax in Python. It is syntactic sugar for func = decorator(func).

In Plain Terms

Imagine a transparent sleeve over a function — the sleeve adds logging, timing, or caching without touching the original. The function doesn't know it's wrapped, and callers don't need to change either.

import functools

def register_calls(func):
    """Decorator: records every call in a registry dict."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if args[0].__name__ == 'show_registry':
            return wrapper.registry
        result = func(*args, **kwargs)
        wrapper.registry[len(wrapper.registry)] = (
            args[0].__name__, result
        )
        return result
    wrapper.registry = {}
    return wrapper

def show_registry(): pass

@register_calls
def call_with_reg(func, x):
    return func(x)

call_with_reg(str.upper, 'hello')  # 'HELLO' — logged
call_with_reg(show_registry)       # {0: ('upper', 'HELLO')}

Key takeaway: Always use @functools.wraps(func) inside a decorator — without it, the wrapper replaces the original function's __name__ and docstring, which breaks debugging.

Closures

A closure is an inner function that remembers variables from its enclosing scope even after the outer function has returned. The inner function "closes over" the variables it references.

In Plain Terms

Imagine a factory that builds customised stamp-makers. Each stamp-maker remembers the design it was configured with. The factory finishes its job, but each stamp still carries its own memory of its design.

# multiplier_factory returns a closure — Python
def multiplier_factory(factor):
    def multiplier(x):
        return x * factor   # 'factor' lives in the closure
    return multiplier

double = multiplier_factory(2)
triple = multiplier_factory(3)
double(5)   # 10
triple(5)   # 15
# 'factor' is gone from scope but both closures
# each hold their own copy of it

Key takeaway: Closures are the foundation of Python's decorator pattern and JavaScript's module pattern — any time state needs to outlive a function call without using a class, reach for a closure.

Lambda & Arrow Functions

Anonymous functions defined inline — useful for short, one-off operations passed to higher-order functions.

Lambda & Arrow Functions

Python's lambda creates a nameless function limited to a single expression. JavaScript's arrow functions (=>) are more powerful: they can have a block body, and crucially they do not rebind this — making them the standard choice for callbacks inside class methods.

In Plain Terms

A lambda is a disposable tool. Instead of naming a function just to pass it once, you write it inline: sorted(people, key=lambda p: p.age). In JS, arrow functions also solve the infamous this problem — they borrow this from the surrounding scope rather than creating their own.

# lambda: anonymous single-expression function
square = lambda x: x ** 2
square(5)   # 25

# Most common use: as a key function
people = [('Alice', 30), ('Bob', 25), ('Carol', 35)]
sorted(people, key=lambda p: p[1])
# [('Bob', 25), ('Alice', 30), ('Carol', 35)]

# With map and filter
list(map(lambda x: x * 2, [1, 2, 3]))   # [2, 4, 6]
list(filter(lambda x: x > 1, [0, 1, 2, 3])) # [2, 3]

# Lambda is limited to ONE expression — use def for multi-line

Key takeaway: Prefer list comprehensions over map(lambda ...) in Python — comprehensions are more readable. Use lambda when passing a key function to sorted(), min(), or max().

Recursion & Memoization

Functions that call themselves to solve a problem by reducing it to smaller instances — and caching to avoid redundant computation.

Recursion & @lru_cache

A recursive function calls itself with a simpler subproblem until it reaches a base case. Without memoization, naive recursion recomputes the same subproblems exponentially. Python's @functools.lru_cache stores previous results automatically. JavaScript can use a Map as a manual cache or a generic memoize wrapper.

In Plain Terms

Fibonacci without caching recalculates fib(30) over a billion times. With @lru_cache, each unique input is calculated exactly once and stored — turning an O(2ⁿ) algorithm into O(n). It's like writing your answers on a notepad instead of solving the same math problem from scratch every time.

from functools import lru_cache

# Without cache: O(2^n) — extremely slow for large n
def fib_slow(n):
    if n <= 1: return n
    return fib_slow(n - 1) + fib_slow(n - 2)

# With cache: O(n) — each value computed once
@lru_cache(maxsize=None)
def fib(n):
    if n <= 1: return n
    return fib(n - 1) + fib(n - 2)

fib(50)          # 12586269025 — instant
fib.cache_info()  # CacheInfo(hits=48, misses=51, ...)

# Recursion for tree traversal
def flatten(lst):
    result = []
    for item in lst:
        if isinstance(item, list):
            result.extend(flatten(item))
        else:
            result.append(item)
    return result

flatten([1, [2, [3, 4]], 5])  # [1, 2, 3, 4, 5]

Key takeaway: Python's default recursion limit is 1 000 frames — for deep recursion, use iterative solutions with an explicit stack or increase the limit with sys.setrecursionlimit().

Objects

Object-oriented programming lets you model the world as interacting entities with state and behaviour. Python leans on metaclasses and multiple inheritance; JavaScript uses prototype chains and Proxies.

Classes & Inheritance

Python metaclasses intercept class creation itself — letting you instrument or transform classes as objects.

Metaclasses

A metaclass is the class of a class. When Python creates a new class, it calls the metaclass's __new__ and __init__. This lets you hook into and transform class creation — adding methods, enforcing constraints, or logging instantiation.

In Plain Terms

A class creates objects. A metaclass creates classes. It is the blueprint of blueprints — if you want to add a "last modified" timestamp to every class in your framework automatically, that is a job for a metaclass.

class Meta(type):
    def __new__(meta, name, bases, dct):
        # called when a new class is *defined*
        Recorder.record += name + '(n)'
        return super(Meta, meta).__new__(meta, name, bases, dct)
    def __init__(cls, name, bases, dct):
        # called when a new class is *initialised*
        Recorder.record += name + '(i)'
        super(Meta, cls).__init__(name, bases, dct)
    def __call__(cls, *args, **kwargs):
        # called when the class is *instantiated*
        Recorder.record += cls.__name__ + '(c)'
        return type.__call__(cls, *args, **kwargs)

# Dynamically create a class using Meta
P1 = Meta('P1', (), {'__init__': lambda self: None})
# Recorder.record == 'P1(n)P1(i)'
P1()
# Recorder.record == 'P1(n)P1(i)P1(c)'
Also in JavaScript (Proxy)

Key takeaway: Metaclasses are powerful but rarely needed in application code. Reach for them in frameworks and ORMs — where you need to enforce class-level contracts across many user-defined subclasses.

Multiple Inheritance & MRO

Python supports multiple inheritance — a class can inherit from several parents. Python's Method Resolution Order (MRO) uses C3 linearisation to determine which parent's method wins when there is a conflict.

In Plain Terms

If a child class has two parents and both define greet(), which one wins? Python walks a strict left-to-right depth-first order (the MRO) and picks the first match. Call ClassName.__mro__ to inspect the order.

class A:
    def greet(self): return 'A'

class B(A):
    def greet(self): return 'B'

class C(A):
    pass   # inherits A.greet

class D(B, C):
    pass

D().greet()     # 'B'  — MRO: D → B → C → A
D.__mro__       # (<D>, <B>, <C>, <A>, <object>)
super(B, D()).greet()  # 'A'  — skips B in the chain

Key takeaway: Always call super().__init__() in multi-inheritance hierarchies — it ensures all parent __init__ methods in the MRO chain are called exactly once.

Dunder / Magic Methods

Special double-underscore methods that let user-defined classes hook into Python's built-in operations like printing, comparison, arithmetic, and container access.

Dunder Methods (__repr__, __len__, __add__)

Dunder (double-underscore) methods define how instances behave with Python's built-in functions and operators. __repr__ / __str__ control string display, __len__ enables len(), __add__ overloads +, and __getitem__ / __setitem__ make objects subscriptable with [].

In Plain Terms

When you write len(my_obj), Python calls my_obj.__len__(). Dunders are the hooks that let your class play nicely with Python's built-in syntax. Once you implement them, your objects feel native — they work with sorted(), in checks, +, and everything else you'd expect.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):           # repr(v) → "Vector(1, 2)"
        return f"Vector({self.x}, {self.y})"

    def __add__(self, other):       # v1 + v2
        return Vector(self.x + other.x, self.y + other.y)

    def __len__(self):             # len(v)
        return 2

    def __eq__(self, other):       # v1 == v2
        return self.x == other.x and self.y == other.y

    def __getitem__(self, idx):    # v[0], v[1]
        return (self.x, self.y)[idx]

v1 = Vector(1, 2)
v2 = Vector(3, 4)
v1 + v2       # Vector(4, 6)
len(v1)       # 2
v1[0]         # 1
v1 == Vector(1, 2)  # True

Key takeaway: Always implement __repr__ first — it's used in the REPL, debugging, and logging. If you implement __eq__, also implement __hash__ (or set it to None) — otherwise your objects won't work in sets or as dict keys.

Iterables

Generators produce values one at a time on demand — they are memory-efficient iterables that can pause and resume execution via yield.

Generators & yield

A generator function uses yield instead of return. Each call to next() resumes the function from where it paused. You can also send values back in via generator.send(value).

In Plain Terms

A generator is like a vending machine that dispenses one item at a time. It waits patiently between each dispense. Unlike a list that loads everything into memory, a generator produces each item only when asked — ideal for large or infinite sequences.

import string, random, statistics

def random_character_generator():
    chars_to_remove = ""
    while True:
        chars = string.ascii_lowercase
        for ch in chars_to_remove:
            chars = chars.replace(ch, "")
        char_to_yield = random.choice(chars)
        chars_to_remove = (yield char_to_yield)
        # caller sends back letters to exclude

rcg = random_character_generator()
rcg.send(None)   # prime the generator

result = []
for _ in range(20):
    c = rcg.send('aeiou')  # exclude vowels
    result.append(c)
# result contains 20 consonants

Key takeaway: Use generators whenever you process a sequence larger than fits in memory, or when you need a lazy pipeline — yield from lets generators delegate to sub-generators cleanly.

Custom Iterables (__iter__ / __next__)

Any class that implements __iter__ (returning self) and __next__ (returning the next value or raising StopIteration) can be used in a for loop.

In Plain Terms

The iterator protocol is like a playlist interface — as long as your object knows "what's current" and "what's next", Python's for loop and list comprehensions will work with it natively, no changes needed on their side.

class Countdown:
    def __init__(self, start):
        self.current = start
    def __iter__(self):
        return self
    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

list(Countdown(5))   # [5, 4, 3, 2, 1]
for n in Countdown(3):
    print(n)            # 3, 2, 1

Key takeaway: A generator function is the easiest way to make an iterable — only implement the full __iter__ / __next__ protocol when you need the class to carry additional state or methods alongside iteration.

Data Processing

Python's ecosystem excels at tabular data. Pandas is the go-to library for DataFrames — think of it as Excel inside Python. NumPy underpins Pandas and is the standard for numerical array operations. SQL remains the lingua franca for querying relational databases and mirrors many Pandas operations.

Selection & Reshaping

Selecting subsets of rows and columns, and reshaping data between wide and long formats (pivot / melt / stack).

DataFrame Selection with MultiIndex

Pandas .loc selects by label; .iloc by integer position. pd.IndexSlice makes multi-level index selection readable. NumPy arrays support similar slicing but operate on homogeneous numerical data.

In Plain Terms

Selecting from a MultiIndex DataFrame is like addressing a grid with named row groups and column groups — instead of saying "row 3, column 2" you say "rows where N='B', column ('c',4)". More readable, less error-prone.

import pandas as pd

df = pd.DataFrame(
    [[11, 12, 13], [14, 15, 16],
     [17, 18, 19], [20, 21, 22]],
    index=pd.MultiIndex.from_product(
        [['A', 'B'], [1, 2]], names=['N', 'V']),
    columns=pd.MultiIndex.from_tuples(
        [('c', 3), ('c', 4), ('d', 4)], names=['n', 'v']))

idx = pd.IndexSlice
# Select rows where N='B', column ('c', 4)
df.loc[idx['B', :], [('c', 4)]]
#           (c, 4)
# (B, 1)       18
# (B, 2)       21

Key takeaway: Prefer .loc over .iloc in production code — label-based selection is robust to row reordering; integer-position selection silently breaks when the DataFrame changes shape.

Pivot & Melt (Wide ↔ Long)

pd.melt() unpivots a wide DataFrame to long format (one row per observation). .pivot_table() does the reverse — turning long format back to wide. The SQL equivalents are UNPIVOT and conditional aggregation.

In Plain Terms

Wide format: one row per person, each year's score in its own column. Long format: one row per (person, year) pair with a single "score" column. Long format is easier to plot and aggregate; wide is easier to read.

# Wide → Long → Wide round-trip — Python (Pandas)
dg = pd.melt(df.reset_index(), id_vars=['N', 'V'])
# each column becomes a separate row

dg = dg.pivot_table(
    index=['N', 'V'],
    columns=['n', 'v'],
    values=['value'])
dg.columns = dg.columns.droplevel(0)  # drop extra level

all(df == dg)  # True — round-trip is lossless

Key takeaway: Store data in long format in databases — it is more normalised, easier to extend with new variables, and naturally handled by GROUP BY. Pivot to wide only for display or for specific algorithms that expect it.

Merging

Combining DataFrames by rows (concat) or by matching keys (merge) — directly equivalent to SQL UNION and JOIN.

Concat & Merge (Join Types)

pd.concat() stacks DataFrames along rows or columns. pd.merge() performs SQL-style joins (inner, left, right, outer) on a key column. The indicator=True flag adds a _merge column showing where each row came from.

In Plain Terms

Concat is like physically stacking two spreadsheets. Merge is like a Venn diagram — inner keeps only the overlap, outer keeps everything (filling gaps with NaN), left keeps the left sheet's rows even without a match.

import pandas as pd

df = pd.DataFrame({'a': [1, 2], 'b': [3, 4]}, index=['A', 'B'])
dg = pd.DataFrame({'b': [4, 5], 'c': [6, 7]}, index=['C', 'A'])

# Row-stack (like SQL UNION ALL)
pd.concat([df, dg])
#    a    b    c
# A  1.0  3.0  NaN
# B  2.0  4.0  NaN
# C  NaN  4.0  6.0
# A  NaN  5.0  7.0

# Outer join on column 'b' (like SQL FULL OUTER JOIN)
pd.merge(df, dg, on='b', how='outer', indicator=True)
#    a    b    c    _merge
# 0  1.0  3  NaN  left_only
# 1  2.0  4  6.0  both
# 2  NaN  5  7.0  right_only

Key takeaway: Use indicator=True when debugging merges — the _merge column immediately reveals unexpected duplicates or missing matches that would otherwise silently corrupt downstream analysis.

SQL Join Types

SQL's join vocabulary maps directly to Pandas merge modes. Understanding the four core join types is essential for writing correct queries.

In Plain Terms

Think of two circles in a Venn diagram. INNER JOIN returns only the overlap. LEFT JOIN returns everything from the left circle plus the overlap. RIGHT JOIN is the mirror. FULL OUTER JOIN returns both circles entirely — filling unmatched sides with NULL.

-- SQL joins — equivalent to Pandas merge how= modes

-- INNER JOIN (how='inner'): only matched rows
SELECT df.a, df.b, dg.c
FROM df INNER JOIN dg ON df.b = dg.b;

-- LEFT JOIN (how='left'): all df rows + matches from dg
SELECT df.a, df.b, dg.c
FROM df LEFT JOIN dg ON df.b = dg.b;

-- FULL OUTER via LEFT + RIGHT UNION (MySQL workaround)
SELECT df.a, df.b, dg.c
FROM df LEFT JOIN dg ON df.b = dg.b
UNION ALL
SELECT df.a, dg.b, dg.c
FROM df RIGHT JOIN dg ON df.b = dg.b
WHERE df.b IS NULL;

-- UNION (stack rows, deduplicate — like pd.concat + drop_duplicates)
SELECT * FROM df
UNION
SELECT * FROM dg;

Key takeaway: UNION deduplicates rows; UNION ALL keeps duplicates and is faster. Use UNION ALL unless you explicitly need deduplication.

GroupBy & Aggregation

Splitting data into groups, applying a function to each group, and combining results — the core pattern behind summary statistics, reports, and dashboards.

GroupBy, Aggregation & Window Functions

Pandas groupby() splits a DataFrame by unique values in one or more columns, then .agg() applies one or more functions per group. transform() returns a result with the same shape as the original — useful for adding a group statistic back as a column. SQL's GROUP BY + window functions (OVER PARTITION BY) are the exact equivalents.

In Plain Terms

Imagine a spreadsheet of sales — one row per transaction. GroupBy is "calculate totals per region." transform is "add each transaction's regional total as a new column so I can compute each transaction's % share." The difference: agg shrinks the table; transform keeps it the same size.

import pandas as pd

sales = pd.DataFrame({
    'region': ['N','N','S','S','S'],
    'product': ['A','B','A','B','A'],
    'revenue': [100,200,150,80,120]
})

# agg — shrinks to one row per group
sales.groupby('region')['revenue'].agg(['sum', 'mean', 'count'])
#         sum   mean  count
# N        300  150.0      2
# S        350  116.7      3

# Multiple grouping keys + custom aggregation
sales.groupby(['region', 'product']).agg(
    total=('revenue', 'sum'),
    n=('revenue', 'count')
)

# transform — keeps original shape, adds group stat
sales['region_total'] = sales.groupby('region')['revenue'].transform('sum')
sales['pct_of_region'] = sales['revenue'] / sales['region_total'] * 100

Key takeaway: Use transform() instead of agg() + merge() when you need to add a group-level statistic back onto the original rows — it's cleaner and avoids a join.

Error Handling

Errors are inevitable — the question is whether your program crashes or recovers gracefully. Both Python and JavaScript use a try/catch pattern, but Python's is more expressive with multiple except clauses, else, and finally.

try / except / finally

Catching and recovering from exceptions — using finally for cleanup that must always run.

try / except / else / finally

Python's try block runs code that might fail. except catches specific exception types, else runs only if no exception occurred, and finally always runs — perfect for releasing resources (files, database connections). JavaScript uses try / catch / finally with the same structure.

In Plain Terms

Think of try/except like crossing a rope bridge: try is the attempt, except catches you if you fall, else celebrates if you made it safely, and finally retracts the bridge regardless of what happened. Always be specific about which exception you catch — catching Exception blindly hides bugs.

# Python: try / except / else / finally
def safe_divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Cannot divide by zero")
        return None
    except TypeError as e:
        print(f"Type error: {e}")
        return None
    else:
        print("Division succeeded")  # only if no exception
        return result
    finally:
        print("Cleanup — always runs")

# File handling with context manager (preferred over try/finally)
with open('data.txt', 'r') as f:
    content = f.read()
# file is automatically closed even if read() raises

Key takeaway: Python's with statement (context manager) is cleaner than manual try/finally for resource cleanup — implement __enter__ / __exit__ on your own classes to support it.

Custom Exceptions

Defining your own exception hierarchy so callers can catch domain-specific errors without relying on generic built-in types.

Custom Exception Classes

Inheriting from Exception (Python) or Error (JavaScript) lets you create named, catchable exceptions that carry domain-specific context. A well-designed exception hierarchy lets callers catch at exactly the right level of specificity — except ValidationError is far clearer than except ValueError.

In Plain Terms

Built-in exceptions are like generic medical codes. Custom exceptions are like specific diagnoses — "PaymentDeclinedError" tells you exactly what went wrong and lets you handle it differently from "NetworkTimeoutError" even though both could be caught as "AppError" at the top level.

# Custom exception hierarchy — Python
class AppError(Exception):
    """Base class for all application errors."""

class ValidationError(AppError):
    def __init__(self, field, message):
        self.field = field
        super().__init__(f"{field}: {message}")

class NotFoundError(AppError):
    def __init__(self, resource, id_):
        super().__init__(f"{resource} {id_} not found")

# Raise and catch at the right level
def get_user(user_id):
    if not isinstance(user_id, int):
        raise ValidationError('user_id', 'must be an integer')
    if user_id == 0:
        raise NotFoundError('User', user_id)

try:
    get_user(0)
except ValidationError as e:
    print(f"Invalid input: {e.field}")
except NotFoundError:
    print("Resource missing")
except AppError:
    print("General app error")  # catches all subclasses

Key takeaway: Always re-raise unexpected exceptions — catching except Exception (Python) or a bare catch (JS) without re-throwing hides bugs. In JavaScript, always set this.name = this.constructor.name in custom errors so stack traces are readable.

Async Programming

Asynchronous code lets a program do other work while waiting for slow operations — network calls, disk I/O, timers. Both Python and JavaScript use async / await, but their underlying models differ: Python uses an explicit event loop (asyncio), while JavaScript's event loop is always running in the runtime.

async / await

Writing asynchronous code that reads like synchronous code — pausing at await points without blocking the thread.

async / await & Concurrent Tasks

An async function always returns a coroutine (Python) or Promise (JavaScript). await suspends the current coroutine until the awaited result is ready, yielding control back to the event loop so other tasks can run. Running tasks concurrently with asyncio.gather() (Python) or Promise.all() (JS) is far faster than awaiting them one at a time.

In Plain Terms

Awaiting tasks in sequence is like ordering coffee, waiting until it arrives, then ordering toast, then waiting. Using gather() / Promise.all() is like ordering both at the same time and picking up whichever arrives first. The total wait time is the longest individual wait, not the sum of all waits.

import asyncio

async def fetch_data(url: str) -> dict:
    # Simulate a network call
    await asyncio.sleep(1)
    return {'url': url, 'data': '...'}

async def main():
    # Sequential — takes 3 seconds total
    r1 = await fetch_data('/a')
    r2 = await fetch_data('/b')
    r3 = await fetch_data('/c')

    # Concurrent — takes ~1 second total
    results = await asyncio.gather(
        fetch_data('/a'),
        fetch_data('/b'),
        fetch_data('/c'),
    )
    return results

asyncio.run(main())

Key takeaway: The most common async mistake is putting await inside a loop — each iteration waits before starting the next. Instead, create all tasks first, then await asyncio.gather() / Promise.all() to run them concurrently.

Promises & Event Loop

How JavaScript's single-threaded event loop handles concurrency — and how Promises represent eventual values.

Promises & the Event Loop

A Promise is an object representing the eventual result of an asynchronous operation — it is either pending, fulfilled, or rejected. JavaScript's event loop processes the call stack synchronously, then drains the microtask queue (Promise callbacks) before handling the next macrotask (setTimeout, I/O). Python's asyncio uses an explicit event loop that must be started with asyncio.run().

In Plain Terms

The event loop is a waiter who handles one table at a time. When a table (task) says "I'm waiting for my food (I/O)", the waiter moves on to another table. When the kitchen (OS) signals "food's ready", the waiter adds that table back to the queue. No one is ever blocked — the waiter is always busy.

// Promises — explicit .then() chain (pre-await style)
const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve('done'), 1000);
});

promise
  .then(result => { console.log(result); return result + '!'; })
  .then(result => console.log(result))   // 'done!'
  .catch(err  => console.error(err))
  .finally(()  => console.log('settled'));

// Promise combinators
Promise.all([p1, p2, p3])        // all must succeed
Promise.allSettled([p1, p2, p3]) // waits for all, ignores failures
Promise.race([p1, p2, p3])       // first to settle wins
Promise.any([p1, p2, p3])        // first to SUCCEED wins

// Event loop ordering demo
console.log('1 sync');
setTimeout(() => console.log('3 macro'), 0);
Promise.resolve().then(() => console.log('2 micro'));
// Output: 1 sync → 2 micro → 3 macro

Key takeaway: In JavaScript, microtasks (Promise callbacks) always run before the next macrotask (setTimeout). This means code immediately after Promise.resolve().then() runs before a setTimeout(..., 0) — a subtle ordering effect that trips up many developers.