The Python fundamentals article gathers the four tools that turn Python into a data language: matplotlib for seeing data, dictionaries for looking it up, DataFrames for holding it, and loops for walking through it. This workbook runs one idea at a time across all four. You will draw the three core plots, build and query a dictionary, select from a DataFrame with locand iloc, filter it with boolean logic, and loop over lists, dictionaries, arrays, and DataFrame rows. None of it is advanced, and all of it is the vocabulary every later article assumes you already speak.
1. The line plot
The line plot is matplotlib’s tool for a trend over time. You pass an x sequence and a y sequence, then label the axes and title so the chart explains itself.
import matplotlibmatplotlib.use("Agg") # lets the script save a file without a displayimport matplotlib.pyplot as pltyear = [2000, 2005, 2010, 2015, 2020, 2025]population = [6.1, 6.5, 6.9, 7.3, 7.8, 8.1] # billionsplt.plot(year, population) # x first, then yplt.xlabel("Year")plt.ylabel("World population (billions)")plt.title("Population over time")plt.savefig("line_plot.png", dpi=150, bbox_inches="tight")print("saved line_plot.png")
plt.plot(year, population) connects the points in order, which is why a line plot only makes sense when the x axis has a natural sequence like time. The three labelling calls are not decoration: an unlabelled chart is unreadable to anyone but its author, so xlabel, ylabel, and title are part of drawing the plot, not an afterthought. savefig writes the figure to disk; in a notebook you would call plt.show() instead.
2. The scatter plot, in more than two dimensions
A scatter plot shows the relationship between two variables as unconnected points, which is right when there is no sequence to join. Size, colour, and transparency let you encode extra variables on the same plot.
import matplotlibmatplotlib.use("Agg")import matplotlib.pyplot as pltgdp_per_capita = [1500, 4000, 12000, 35000, 55000]life_expectancy = [58, 65, 72, 79, 82]population = [200, 500, 1300, 80, 40] # millions -> point sizeregion_code = [0, 1, 1, 2, 2] # colour groupsplt.scatter( gdp_per_capita, life_expectancy, s=population, # size encodes a third variable c=region_code, # colour encodes a fourth alpha=0.6, # transparency so overlaps show cmap="viridis",)plt.xscale("log") # GDP spans orders of magnitudeplt.xlabel("GDP per capita (log scale)")plt.ylabel("Life expectancy")plt.savefig("scatter_plot.png", dpi=150, bbox_inches="tight")print("saved scatter_plot.png")
scatter plots points without connecting them, so it reveals correlation rather than trend. The extra arguments are where it earns its keep: s maps each point’s size to population, c colours points by region, and alpha makes overlapping points visible instead of a solid blob, so four variables live on two axes. plt.xscale("log") is the fix for a variable like GDP that ranges over orders of magnitude, spreading the crowded low end so the pattern is legible.
3. The histogram, with bins and ticks
A histogram shows the distribution of a single variable by counting how many values fall into each bin. The bins argument controls the resolution, and custom ticks make the axis readable.
import matplotlibmatplotlib.use("Agg")import matplotlib.pyplot as pltages = [5, 12, 18, 22, 23, 25, 31, 34, 38, 41, 45, 52, 58, 63, 70, 71, 82]plt.hist(ages, bins=7) # split the range into 7 bucketsplt.xlabel("Age")plt.ylabel("Count")plt.title("Age distribution")plt.xticks([0, 20, 40, 60, 80]) # place ticks where you want themplt.grid(axis="y", alpha=0.3)plt.savefig("histogram.png", dpi=150, bbox_inches="tight")print("saved histogram.png")
plt.hist does the counting for you: it divides the range into bins equal buckets and draws a bar for how many values land in each, which is how you see shape, whether the data is bunched, spread, or skewed. The bin count is a judgement call, since too few hide the shape and too many make it noisy. xticks overrides matplotlib’s automatic tick positions with your own list, which matters when the defaults land on awkward numbers.
4. Dictionaries: lookup by key
A dictionary stores key-value pairs and retrieves a value by its key instantly, which is what you want whenever data is naturally “look up X to get Y”. This example runs the full lifecycle: create, access, add, test, and delete.
capitals = { "Japan": "Tokyo", "Brazil": "Brasilia", "Canada": "Ottawa",}print(capitals["Japan"]) # access by key: Tokyocapitals["Egypt"] = "Cairo" # add a new paircapitals["Brazil"] = "Rio" # update: same key overwritesprint("Egypt" in capitals) # membership test: Truedel capitals["Canada"] # delete a pairprint(capitals)
A dictionary is defined by its keys, which must be unique, so assigning to an existing key updates it while assigning to a new key adds it, which is why capitals["Egypt"] = "Cairo" and capitals["Brazil"] = "Rio" do different things from identical-looking syntax. The in test checks for a key, not a value, and is instant regardless of size, which is the dictionary’s whole advantage over scanning a list. del removes a pair. This lookup-by-key model is the foundation the next example builds a DataFrame on.
5. DataFrames from a dictionary, and column selection
A DataFrame is pandas’ table, and the most direct way to build one is from a dictionary where each key is a column. Selecting a column with single versus double brackets returns two different things, which trips up every beginner once.
import pandas as pddata = { "country": ["USA", "Japan", "Brazil", "Canada"], "vehicles_per_1000": [837, 591, 350, 685], "drives_right": [True, True, True, True],}cars = pd.DataFrame(data)cars.index = ["US", "JP", "BR", "CA"] # give the rows labelsprint(cars)# single brackets -> a Series (one dimension)print(type(cars["country"])) # <class 'pandas.Series'># double brackets -> a DataFrame (a table, even with one column)print(type(cars[["country"]])) # <class 'pandas.DataFrame'># a list inside selects several columns as a DataFrameprint(cars[["country", "drives_right"]])
Each dictionary key becomes a column and its list becomes that column’s values, which is why a dictionary is the natural literal for a small table. The bracket distinction is the subtlety worth pinning: cars["country"] returns a one-dimensional Series, while cars[["country"]] returns a DataFrame that happens to have one column, and the double-bracket form is also how you select several columns at once by passing a list of names. Reach for double brackets whenever you want the result to stay a table.
6. loc and iloc: label versus position
Selecting rows and columns comes in two flavours. loc uses labels, the names of rows and columns, and iloc uses integer positions. Confusing them is the most common pandas indexing error.
import pandas as pdcars = pd.DataFrame({ "country": ["USA", "Japan", "Brazil", "Canada"], "vehicles_per_1000": [837, 591, 350, 685],}, index=["US", "JP", "BR", "CA"])# loc: by LABELprint(cars.loc["JP"]) # the Japan rowprint(cars.loc["JP", "country"]) # a single cell by labelsprint(cars.loc[["US", "CA"], ["country"]]) # rows and columns by label# iloc: by POSITIONprint(cars.iloc[1]) # the second row (0-indexed)print(cars.iloc[1, 0]) # row 1, column 0print(cars.iloc[[0, 3], [0]]) # positions, same result as above
The two answer the same questions in different currencies. loc["JP"] finds the row labelled JP no matter where it sits, while iloc[1] finds whatever row is in position 1, and the two agree here only because JP happens to be second. The general form is [rows, columns], so loc["JP", "country"] reads one cell by two labels and iloc[1, 0] reads it by two positions. Use loc when you know the names and iloc when you know the order, and never mix a label into iloc or a position into loc.
7. Boolean subsetting: filtering rows by a condition
The most useful selection is not by name or position but by condition: keep the rows where something is true. A comparison on a column produces a boolean Series, and passing it back into the DataFrame keeps only the True rows.
import pandas as pdimport numpy as npcars = pd.DataFrame({ "country": ["USA", "Japan", "Brazil", "Canada", "India"], "vehicles_per_1000": [837, 591, 350, 685, 60],})# a comparison gives a boolean Serieshigh = cars["vehicles_per_1000"] > 500print(high)# use it to keep only the True rowsprint(cars[high])# combine conditions with numpy's element-wise logical operatorsmid = cars[np.logical_and(cars["vehicles_per_1000"] > 100, cars["vehicles_per_1000"] < 700)]print("\nmid-range countries:")print(mid)
The comparison cars["vehicles_per_1000"] > 500 does not return one answer, it returns a True/False value for every row, and indexing the DataFrame with that boolean Series keeps the True ones. Combining conditions needs care: plain Python anddoes not work element-wise on Series, so pandas and numpy provide np.logical_and, np.logical_or, and np.logical_not (or the &, |, ~ operators) that apply row by row. Boolean subsetting is how nearly all real filtering is done, from “customers who spent over £100” to “days above freezing”.
8. Conditionals: if, elif, else
A conditional runs different code depending on a test. if checks the first condition, elif checks the next only if earlier ones failed, and else catches everything remaining. Their order matters.
def classify(vehicles_per_1000): if vehicles_per_1000 > 700: return "very high" elif vehicles_per_1000 > 400: return "high" elif vehicles_per_1000 > 100: return "moderate" else: return "low"for value in [837, 591, 350, 60]: print(value, "->", classify(value))
The branches are tested top to bottom and the first true one wins, so ordering is part of the logic, not a detail. Here the thresholds descend, which is deliberate: a value of 837 passes the first test and returns immediately, never reaching the others. Had the tests been written in ascending order, every large value would match the loosest condition first and the fine distinctions would be lost. Write elif chains from most specific or most extreme to least, and let else mop up whatever falls through.
9. Loops: while and for
The two loop types serve different needs. A while loop repeats until a condition becomes false, and a for loop walks through a known sequence. enumerate gives you the position alongside each item.
# while: repeat until a condition changesbalance = 100months = 0while balance < 150: # keep going until the target is reached balance *= 1.1 # 10% growth per month months += 1print(f"reached 150 after {months} months")# for: walk a known sequencecities = ["Tokyo", "Brasilia", "Ottawa"]for city in cities: print(city.upper())# enumerate: get the index toofor i, city in enumerate(cities): print(f"{i}: {city}")
The distinction is whether you know the number of repetitions in advance. The while loop does not, since it runs until the balance crosses 150, however many months that takes, which makes it right for “keep going until” problems and risky if the condition never turns false. The for loop does know, walking each item of a fixed sequence exactly once, which makes it the safe default. enumerate wraps a sequence so each turn yields both the position and the item, saving you from tracking a counter by hand.
10. Looping over dictionaries, arrays, and DataFrames
Different structures need different iteration syntax. Dictionaries loop with .items(), numpy arrays with np.nditer, and DataFrames with .iterrows(), each unpacking into the pieces you need.
import numpy as npimport pandas as pd# dictionary: .items() gives key and valuecapitals = {"Japan": "Tokyo", "Brazil": "Brasilia"}for country, capital in capitals.items(): print(f"{country}: {capital}")# numpy array: np.nditer walks every elementgrid = np.array([[1, 2], [3, 4]])for value in np.nditer(grid): print(value, end=" ")print()# DataFrame: .iterrows() gives the row label and the row datacars = pd.DataFrame({ "country": ["USA", "Japan"], "vehicles_per_1000": [837, 591],}, index=["US", "JP"])for label, row in cars.iterrows(): print(f"{label}: {row['country']} has {row['vehicles_per_1000']} per 1000")
Each structure exposes its contents differently, and the syntax reflects its shape. A plain for over a dictionary yields only the keys, so .items() is what you use to get key and value together, unpacked into two variables. A numpy array is multi-dimensional, so np.nditer flattens the walk to visit every element regardless of shape. A DataFrame is a table of rows, so .iterrows() yields each row’s label and its data as a Series you index by column name. One caution the fundamentals lead into later: iterrows is fine for learning and small tables, but on large DataFrames a vectorised operation is far faster, which is a lesson for another article.
Work through these and you have the whole article in practice: the three core matplotlib plots with labels, scales, and ticks; dictionaries across their full lifecycle; DataFrames from a dictionary with the single-versus-double-bracket rule; locand iloc for label and position selection; boolean subsetting for conditional filtering; if/elif/else ordering; while and for loops with enumerate; and the right iteration syntax for dictionaries, arrays, and DataFrame rows. These four tools, seeing, looking up, holding, and walking through data, are the base every other datalad article builds on, so time spent here is repaid everywhere later.
See you soon.
[…] Python Fundamentals: 10 Code-Along Examples […]
[…] Python Fundamentals: 10 Code-Along Examples […]
[…] For the full background, read the guide to Python fundamentals: plots, dictionaries, DataFrames, and loops. To practise, work through the 10 code-along examples. […]