The [chi-square test of independence](/tutorials/chi-square-test-of-independence-with-scipy) is the standard way to check whether two categorical variables are related, but it relies on an approximation that breaks down when cell counts are small — typically when any expected count falls below 5. Fisher's exact test solves this by computing the exact probability of the observed table (and all more extreme ones) without any approximation, making it the correct choice for small samples. It is widely used in genetics, clinical trials, and epidemiology, where studies often start with just a few dozen observations. ### Setting up a 2×2 contingency table A 2×2 table cross-tabulates two binary variables. In a case-control genetics study, rows represent disease status and columns represent whether a gene variant is present.
import numpy as np
# Rows: Cases (disease) vs Controls (no disease)
# Cols: Variant present vs Variant absent
table = np.array([[8, 2], # Cases: 8 carry variant, 2 do not
[2, 8]]) # Controls: 2 carry variant, 8 do not
row_labels = ["Cases", "Controls"]
col_labels = ["Variant", "No variant"]
print(f"{'':12} {'Variant':>10} {'No variant':>12} {'Total':>8}")
for label, row in zip(row_labels, table):
print(f"{label:12} {row[0]:>10} {row[1]:>12} {row.sum():>8}")
totals = table.sum(axis=0)
print(f"{'Total':12} {totals[0]:>10} {totals[1]:>12} {table.sum():>8}")- The table is passed as a 2×2 NumPy array (or plain Python list). Fisher's test uses only the four cell counts and the marginal totals. - `table.sum(axis=0)` sums column-wise to get column totals; `table.sum()` gives the grand total. - Notice that no expected cell exceeds 10 observations — chi-square's approximation would be unreliable here, making Fisher's exact the right choice. ### Running the test and reading the odds ratio `scipy.stats.fisher_exact` returns two values: the **odds ratio** (the strength of the association) and the **p-value** (whether the association is statistically significant).
import numpy as np
from scipy.stats import fisher_exact
table = np.array([[8, 2], [2, 8]])
odds_ratio, p_value = fisher_exact(table)
print(f"Odds ratio: {odds_ratio:.3f}")
print(f"p-value (two-sided): {p_value:.4f}")
manual_or = (table[0, 0] * table[1, 1]) / (table[0, 1] * table[1, 0])
print(f"\nManual odds ratio check: {manual_or:.3f}")
if p_value < 0.05:
print("\nSignificant association at α = 0.05")
else:
print("\nNo significant association at α = 0.05")- The odds ratio is computed as `(a × d) / (b × c)` where a, b, c, d are the four cells read left-to-right, top-to-bottom. An odds ratio of 1 means no association; > 1 means the first row has higher odds of being in the first column. - Here OR ≈ 16: the variant is roughly 16 times more likely to appear in cases than in controls. - `fisher_exact` defaults to `alternative='two-sided'`, testing whether there is *any* association. The p-value is exact — not an approximation — because Fisher's test enumerates all possible tables with the same marginals. ### Choosing one-sided or two-sided alternatives When you have a directional hypothesis — e.g., "the variant *increases* disease risk" — a one-sided test is more powerful than two-sided.
import numpy as np
from scipy.stats import fisher_exact
table = np.array([[8, 2], [2, 8]])
_, p_two = fisher_exact(table, alternative="two-sided")
_, p_greater = fisher_exact(table, alternative="greater")
_, p_less = fisher_exact(table, alternative="less")
print(f"Two-sided p: {p_two:.4f} (any association)")
print(f"One-sided (greater) p: {p_greater:.4f} (cases have MORE variant)")
print(f"One-sided (less) p: {p_less:.4f} (cases have LESS variant)")- `alternative='greater'` tests whether the odds ratio is greater than 1 — i.e., whether the first row's first-column count is higher than expected under independence. - `alternative='less'` tests the opposite direction. - The one-sided p is always ≤ the two-sided p. Use one-sided only when the direction was specified *before* seeing the data — otherwise you risk inflating your false positive rate. ### Visualising the association A grouped bar chart of proportions makes the direction and magnitude of the association immediately clear.
import numpy as np
import matplotlib.pyplot as plt
table = np.array([[8, 2], [2, 8]])
groups = ["Cases", "Controls"]
proportions = table[:, 0] / table.sum(axis=1) # fraction with variant in each group
fig, ax = plt.subplots(figsize=(6, 5))
colors = ["tomato", "steelblue"]
bars = ax.bar(groups, proportions, color=colors, edgecolor="black", width=0.5)
for bar, prop, row in zip(bars, proportions, table):
ax.text(bar.get_x() + bar.get_width() / 2, prop + 0.02,
f"{row[0]}/{row.sum()}", ha="center", fontsize=11)
ax.set_ylabel("Proportion carrying the variant")
ax.set_title("Variant frequency: Cases vs Controls\n(Fisher's exact p = 0.023)")
ax.set_ylim(0, 1.1)
plt.tight_layout()
plt.show()- `table[:, 0] / table.sum(axis=1)` computes the proportion of variant carriers within each group — 0.8 for cases and 0.2 for controls. - The fraction labels (e.g., `8/10`) above each bar provide the raw counts alongside the proportions, so readers can judge the sample size at a glance. - The p-value is included in the title so the chart is self-contained for reporting. ### When to use Fisher's instead of chi-square The rule of thumb is: if any *expected* cell count is below 5, use Fisher's exact test. Expected counts are calculated from the row and column totals, not the observed values.
import numpy as np
from scipy.stats import fisher_exact, chi2_contingency
# Small sample: expected counts will be low
small_table = np.array([[4, 1], [1, 6]])
_, p_fisher = fisher_exact(small_table)
chi2, p_chi2, dof, expected = chi2_contingency(small_table, correction=False)
print("Expected cell counts:")
print(expected.round(2))
print(f"\nFisher's exact p: {p_fisher:.4f}")
print(f"Chi-square p: {p_chi2:.4f}")
print(f"\nMin expected count: {expected.min():.2f}")
if expected.min() < 5:
print("→ At least one expected count < 5: use Fisher's exact test")- `chi2_contingency` returns the expected counts matrix alongside the test statistic and p-value. - When expected counts are small, the chi-square approximation is imprecise and can give a different p-value to Fisher's exact test — the exact test should be trusted. - For large samples (all expected counts ≥ 5), both tests give similar p-values and either is acceptable; chi-square extends naturally to tables larger than 2×2, while Fisher's exact is specific to 2×2. ### Conclusion Fisher's exact test is a one-function call in SciPy, but knowing *when* to use it — small samples, binary outcomes, 2×2 tables — and how to interpret the odds ratio makes it a powerful tool for association testing. Always check expected cell counts first: they determine whether chi-square's approximation is safe. For larger contingency tables or when all expected counts are adequate, see [chi-square test of independence with SciPy](/tutorials/chi-square-test-of-independence-with-scipy). To detect differences in a continuous outcome instead of a binary one, see [independent samples t-test with SciPy](/tutorials/independent-samples-t-test-with-scipy).