loc and iloc select rows and columns in one step, written as [rows, columns].
iloc uses positions, counting from 0 like Python lists. df.iloc[0] is the first row, df.iloc[-1] the last, and df.iloc[10:15] is positions 10 to 14: a slice leaves out its end.
loc uses labels: index labels for rows and names for columns. df.loc[25, "product"] is one value. Rows can also be a mask: df.loc[df["units"] > 5, ["product", "revenue"]].
With the default index (0, 1, 2 and so on), labels and positions look the same. After filtering or sorting they differ, and that's when mixing them up causes wrong answers.
loc is also how you change values. Assign to the selection directly:
df.loc[df["region"] == "West", "region"] = "Pacific"
Don't chain two selections, as in df[mask]["region"] = .... That changes a temporary copy and leaves df as it was.