How to find a p-value from a test statistic
Turn a reported t, z, F or chi-square statistic into its p-value by hand and with R's pt(), pnorm(), pf() and pchisq(), compared with full test output.
Updated . Written by Matt Gencer.
On this page
To find a p-value from a test statistic, you need two things besides the statistic itself: the distribution it follows when the null hypothesis is true, and that distribution’s degrees of freedom. The p-value is the area of that distribution at least as extreme as your statistic. In R, four functions cover nearly every case: pt() for t, pnorm() for z, pf() for F and pchisq() for chi-square.
This post shows each one on real output. The statistics below come from analyses of R’s built-in ToothGrowth data (and one logistic regression on a second public dataset), and each hand-computed p-value is set beside the p-value the full test reported. If you want the bigger picture first, what a p-value and a confidence interval each tell you covers interpretation; this post is about the arithmetic.
The rule in one table
| Statistic | Distribution under the null | Degrees of freedom | Tail | In R |
|---|---|---|---|---|
| t | t | from the test (Welch’s can be fractional) | both, for a two-sided test | 2 * pt(-abs(t), df) |
| z | standard normal | none | both, for a two-sided test | 2 * pnorm(-abs(z)) |
| F | F | two: numerator and denominator | upper only | pf(F, df1, df2, lower.tail = FALSE) |
| chi-square | chi-square | one | upper only | pchisq(x, df, lower.tail = FALSE) |
Two decisions do all the work: which distribution, and which tail. Get the distribution from the test you ran, never from the size of the statistic. Get the tail from the hypothesis: t and z statistics can be negative, so a two-sided test counts both tails, while F and chi-square statistics are sums of squares, cannot be negative, and grow as the data move away from the null hypothesis in any direction. For them, only the upper tail is “more extreme”.
What the number means
The p-value is the probability of a statistic at least as far from what the null hypothesis predicts as the one you observed, if the null hypothesis and every assumption of the test were true. The American Statistical Association’s 2016 statement puts the useful half plainly: a p-value can indicate how incompatible the data are with a specified statistical model. It does not measure the probability that the hypothesis is true.
If you test at a significance level fixed before the analysis, often 0.05, a p-value at or below it is called statistically significant; a larger one means a difference was not detected, not that none exists.
By hand: what a printed table can and cannot do
Before software, you used a printed table of critical values. A t table lists, for each row of degrees of freedom, the values of t that cut off tail areas such as 0.10, 0.05 and 0.01. You find your row, see which two critical values your statistic falls between, and conclude that the p-value lies between the corresponding tail areas.
That shows what a p-value is: a position on a known curve. But a table gives a range, not a value; its whole-number rows force a fractional Welch df between rows; and its column headings may be one-tailed or two-tailed areas, a common slip. An exact p-value needs the distribution function itself, which is what the four R functions are.
t statistics: pt()
The first example is Welch’s two-sample t-test comparing tooth length between the two vitamin C delivery methods in ToothGrowth: orange juice and ascorbic acid, 30 animals each, pooled over dose. The engine reported t = 1.915 on 55.31 degrees of freedom and a two-sided p-value of 0.0606.
# Values stored by t.test(), unrounded; the printed output rounds them
t_stat <- 1.915268268695268
df <- 55.30943268264059
2 * pt(-abs(t_stat), df) # two-sided p-value: 0.0606, as reported
pt(t_stat, df, lower.tail = FALSE) # upper tail only (one-sided, OJ > VC)
pt(t_stat, df) # lower tail only (one-sided, OJ < VC)
pt(-abs(t), df) is the area below the negative of the statistic’s absolute value. Because the t distribution is symmetric, that equals the area above the positive value. Doubling it counts both tails. It matches the engine’s stored 0.0606 well beyond the printed digits.
The upper-tail line gives exactly half that value; the lower-tail line gives one minus that half, because the observed t points the other way. A one-sided test is right only if its direction was fixed before seeing the data.
Welch’s test estimates its degrees of freedom from the two group variances, following Welch’s 1947 approximation, so they are rarely a whole number. Plugging in the total sample size minus two, the Student’s t-test value, gives a slightly different and wrong p-value for a Welch statistic. Always take the degrees of freedom from the test that produced the statistic.
z statistics: pnorm()
The ToothGrowth analyses here do not produce a z statistic, so the second example uses MASS’s birthwt data: a logistic regression of low birth weight on maternal smoking, 189 births. Logistic regression reports a Wald z for each coefficient. The coefficient is on the log-odds scale (it is the log of an odds ratio), and the Wald z is that log-odds estimate divided by its standard error, referred to the standard normal distribution. For the smoking coefficient, the Wald z = 2.203 with a two-sided p-value of 0.0276. It tests whether the log-odds coefficient is zero, which is the same as an odds ratio of one.
z <- 2.202647278921316 # Wald z for the smoking log-odds coefficient
2 * pnorm(-abs(z)) # two-sided p-value: 0.0276, as reported
The standard normal has no degrees of freedom, so the call needs only the statistic. That is also why pnorm() is the wrong function for a t statistic. The normal distribution has thinner tails than any t distribution, so applying pnorm() to the Welch t above gives a p-value smaller than the 0.0606 the test reported, and the understatement grows as the degrees of freedom fall. Use pnorm() only for a statistic the method itself defines as a z.
F statistics: pf()
ToothGrowth was designed as a two-factor experiment: two delivery methods crossed with three doses, 10 animals in each of the six cells. The analysis that matches that design is a two-way ANOVA, and it reports one F statistic per term, each with two degrees of freedom: the term’s own, and the residual degrees of freedom of the model.
| Term | F | Degrees of freedom | p-value reported |
|---|---|---|---|
| Delivery method | 15.57 | 1 and 54 | below 0.001 |
| Dose | 92.00 | 2 and 54 | below 0.001 |
| Delivery method by dose interaction | 4.107 | 2 and 54 | 0.0219 |
pf(15.571979452497253, 1, 54, lower.tail = FALSE) # delivery method
pf(91.99996489286714, 2, 54, lower.tail = FALSE) # dose
pf(4.10699109402252, 2, 54, lower.tail = FALSE) # interaction: 0.0219, as reported
All three reproduce the engine’s p-values. The numerator degrees of freedom (the term) come first, the denominator (the residuals) second; swapping them gives a very different answer. Never double an F p-value: a larger F means more variation between means than the residuals explain, in whatever direction, so there is one tail.
The interaction F also says something about the t-test above. Its p-value of 0.0219 says the data are not very compatible with a delivery-method difference that is the same at every dose: in the cell means, orange juice was ahead at 0.5 and 1 mg/day and level with ascorbic acid at 2 mg/day. The Welch t-test ignored dose, so it answered only whether the methods differ on average over these doses, and counted the large differences between doses as noise, one reason its p-value (0.0606) is far larger than the delivery-method F’s (below 0.001). Both are correct for their own models; neither gives the difference at a particular dose. The two-way ANOVA guide explains how to read the interaction, and the engine’s reference pages for Welch’s t-test and the two-way ANOVA list what each method reports.
Chi-square statistics: pchisq()
Many rank-based and categorical tests refer their statistic to a chi-square distribution. The Kruskal-Wallis test is one: its H statistic is compared with a chi-square distribution whose degrees of freedom are the number of groups minus one, as Kruskal and Wallis described in 1952 and as R’s kruskal.test() documents. On ToothGrowth, comparing tooth length across the three doses (pooled over delivery method), the engine reported H = 40.67 on 2 degrees of freedom and a p-value below 0.001.
H <- 40.66893526500194
pchisq(H, df = 2, lower.tail = FALSE) # matches the reported p-value exactly
Like F, only the upper tail counts. The same call handles a chi-square test of independence (degrees of freedom: rows minus one times columns minus one) and a likelihood-ratio test between nested models (the difference in parameters). For Kruskal-Wallis, the chi-square reference is a large-sample approximation to the exact distribution of H, so with very small groups the p-value from pchisq() is approximate. The one-way ANOVA, Welch and Kruskal-Wallis guide and the Kruskal-Wallis reference page explain what the test compares. This run ignored delivery method, so each dose group mixes both methods and the test answers only whether tooth length differs across doses on average. It says nothing about dose within either method, which the interaction F suggests differ.
Checking a reported result
Recomputing the p-value is a quick consistency check on any reported result, your own or one in a paper. For the four examples here, every recomputed value matched what the full test reported:
| Test | Statistic | Function | Reported p | Recomputed |
|---|---|---|---|---|
| Welch’s t-test | t = 1.915, 55.31 df | pt() |
0.0606 | matches |
| Logistic regression, smoking log-odds coefficient | Wald z = 2.203 | pnorm() |
0.0276 | matches |
| Two-way ANOVA, interaction | F = 4.107, 2 and 54 df | pf() |
0.0219 | matches |
| Kruskal-Wallis across doses | H = 40.67, 2 df | pchisq() |
below 0.001 | matches |
When a check against a paper does not match, the usual causes are the wrong tail, the wrong degrees of freedom, rounding, and a statistic never meant for that distribution. Small differences in the third or fourth digit almost always come from rounded inputs; work from the stored, unrounded values. The logistic regression reference page lists the Wald z the engine reports for each coefficient, and the logistic regression guide shows a case where the Wald test and the likelihood-ratio test disagree sharply for the same coefficient.
Common mistakes
- Doubling an F or chi-square p-value, or using
pnorm()for a t. F and chi-square have one tail; a t needspt(). - Forgetting
lower.tail = FALSE. Without it,pf()andpchisq()return the area below the statistic, which for a large statistic is close to one. - Reading the p-value as the probability that the null hypothesis is true. Greenland and colleagues list it among 25 common misinterpretations. The p-value is computed assuming the null hypothesis; it cannot also be the probability of it.
- Recomputing one p-value out of many and treating it alone. If the statistic came from a family of comparisons, the reported p-value may already be adjusted, or should be. The multiple comparisons guide explains when.
Report an effect size with its interval, too
This post stops at the p-value; a write-up should not. A p-value mixes the size of the effect with the sample size: a trivial difference in a large study and a large difference in a small one can produce the same p. The ASA statement’s fifth principle: a p-value does not measure the size of an effect. Report an effect size (a mean difference, odds ratio or standardised effect, naming what it is scaled by) with its confidence interval next to every p-value, not instead of it.
The Welch example shows why. Its p-value of 0.0606 alone says little. The engine’s estimate says more: a mean difference of 3.70 in tooth length (orange juice minus ascorbic acid), with a 95% confidence interval from -0.17 to 7.57, computed without assuming equal variances. That range, from a slight difference the other way to a sizeable one, is what a reader needs and a p-value cannot give.
Rerunning the example
Below the article are the R script the engine generated for the Welch t-test and the output it printed. On the same package versions, it reprints the full Welch t-test output (t, degrees of freedom, p-value, mean difference and its 95% confidence interval; the pt() line above recomputes the same p-value from t), plus a sensitivity calculation of the smallest effect the design could detect with 80% power (not observed power). It does not recompute the assumption checks or the standardised effect size. The two-way ANOVA, Kruskal-Wallis and logistic regression statistics come from separate runs of the same engine on ToothGrowth and MASS’s birthwt data; their scripts are not shown here, and the code blocks above reproduce only their p-values from the stored statistics.
Worked example
Data: Crampton EW (1947). The growth of the odontoblasts of the incisor tooth as a criterion of the vitamin C intake of the guinea pig. Journal of Nutrition 33(5):491-504. https://doi.org/10.1093/jn/33.5.491. Seed: 20261022.
Run on Celeus engine 0.30.0, seed 20261022, public data.
Output
# Results - Test: Welch's t-test - n: 60 - statistic: 1.915 - p-value: 0.0606 - effect size: Cohen's d = 0.4945 (95% CI -0.02151 to 1.006) - 95% CI: [-0.171, 7.571] - seed: 20261022 ## Assumption checks - normality by group (Shapiro-Wilk): passed=FALSE - variance homogeneity (Levene): passed=TRUE (no evidence of unequal variances) - sample size: passed=TRUE (adequate n) ## Power (sensitivity, not observed power) - minimum detectable effect at n=60: Cohen's d = 0.7356 - to detect a conventional medium effect: 64 per group to detect d=0.50 - alpha = 0.05, target power = 0.8
R script
# Celeus - reproduce.R (run from this package folder; bundled data in data/)
# Test: Welch's t-test | engine 0.30.0 | catalog c39-d3cd
# Research context: not provided for this run.
# Reproduces the recorded result deterministically.
set.seed(20261022)
.data_path <- "data/p-value-from-test-statistic.csv"
if (!file.exists(.data_path)) stop("Run reproduce.R from the package folder (the bundled data is in data/).")
data <- readr::read_csv(.data_path, show_col_types = FALSE) # needs the readr package
clean_cells <- function (df)
{
ctrl_collapse <- "[\r\n\t\v\f]+"
zero_width_chars <- intToUtf8(c(8203L, 65279L), multiple = TRUE)
uni_space_chars <- intToUtf8(c(160L, 8239L, 8199L), multiple = TRUE)
clean_text <- function(x) {
out <- x
for (ch in zero_width_chars) out <- gsub(ch, "", out,
fixed = TRUE)
out <- gsub(ctrl_collapse, " ", out)
for (ch in uni_space_chars) out <- gsub(ch, " ", out,
fixed = TRUE)
trimws(gsub(" +", " ", out))
}
changed_cols <- character(0)
cells_cleaned <- 0L
retyped <- character(0)
for (j in seq_along(df)) {
col <- df[[j]]
if (!is.character(col))
next
cleaned <- clean_text(col)
n_changed <- sum(!is.na(col) & !is.na(cleaned) & col !=
cleaned, na.rm = TRUE)
if (n_changed > 0L) {
changed_cols <- c(changed_cols, names(df)[j])
cells_cleaned <- cells_cleaned + n_changed
}
df[[j]] <- cleaned
non_blank <- cleaned[!is.na(cleaned) & nzchar(cleaned)]
if (length(non_blank) && all(grepl("^[+-]?(0|[1-9][0-9]*)([.][0-9]+)?$",
non_blank))) {
parsed <- suppressWarnings(as.numeric(cleaned))
was_blank <- is.na(cleaned) | !nzchar(cleaned)
lost <- (is.na(parsed) | is.infinite(parsed)) & !was_blank
if (!any(lost)) {
finite <- parsed[!is.na(parsed)]
all_whole <- all(finite == floor(finite))
in_range <- all(abs(finite) <= .Machine$integer.max)
df[[j]] <- if (all_whole && in_range)
as.integer(parsed)
else parsed
retyped <- c(retyped, names(df)[j])
}
}
}
names(df) <- clean_text(names(df))
if (length(changed_cols)) {
attr(df, "ingest_clean_columns") <- changed_cols
attr(df, "ingest_clean_cells_cleaned") <- cells_cleaned
}
if (length(retyped))
attr(df, "ingest_clean_retyped") <- retyped
df
}
data <- clean_cells(data)
# Complete-case analysis: drop rows missing any analysis value.
.acols <- c("len", "supp")
data <- data[stats::complete.cases(data[, .acols, drop = FALSE]), , drop = FALSE]
result <- t.test(`len` ~ `supp`, data = data, var.equal = FALSE)
print(result)
# Sensitivity: minimum detectable effect at n=60 (power=0.8, alpha=0.05). NOT observed power.
power <- pwr::pwr.t2n.test(n1 = 30, n2 = 30, sig.level = 0.05, power = 0.8)
print(power)Common questions
How do I find a p-value from a test statistic?
Identify the distribution the statistic follows when the null hypothesis is true (t, standard normal, F or chi-square) and its degrees of freedom, then compute the probability of a value at least as extreme. In R that is pt(), pnorm(), pf() or pchisq() with lower.tail = FALSE, doubled for a two-sided t or z test.
Why do I multiply by 2 for a t-test but not for an F-test?
A two-sided t or z test counts extreme values in both tails, so the area beyond the absolute value of the statistic is doubled. F and chi-square statistics are sums of squared quantities and cannot be negative, so departures from the null hypothesis all push them upward and only the upper tail counts.
Can I use the normal distribution instead of the t distribution?
Only for a statistic that is defined as a z, such as a Wald z from logistic regression. For a t statistic, the normal distribution has thinner tails than the t distribution, so pnorm() gives a p-value that is too small, and the error grows as the degrees of freedom fall.
Why does my hand calculation differ slightly from the software output?
Usually because the statistic or the degrees of freedom were rounded before the calculation. Welch's t-test has fractional degrees of freedom, and a statistic copied to two decimals changes the later digits of the p-value. Use the unrounded values the software stores, not the rounded ones it prints.
How do I find a p-value from a test statistic in Excel or Python?
In Excel, T.DIST.2T(ABS(t), df) gives a two-sided t p-value, 2*(1-NORM.S.DIST(ABS(z), TRUE)) a two-sided z p-value, F.DIST.RT(F, df1, df2) an F p-value and CHISQ.DIST.RT(x, df) a chi-square p-value. Excel's help describes the t degrees of freedom as an integer and says F.DIST.RT and CHISQ.DIST.RT truncate a fractional one, so a fractional Welch df is safer in R. In Python, scipy.stats offers t.sf, norm.sf, f.sf and chi2.sf, the upper-tail areas; double t.sf(abs(t), df) or norm.sf(abs(z)) for a two-sided test.
How do I find a left-tailed or right-tailed p-value?
For a right-tailed test, take the area above the statistic: pt(t, df, lower.tail = FALSE) in R. For a left-tailed test, take the area below it: pt(t, df). Do not double either. The direction must come from the hypothesis written before the data were seen, not from the sign of the statistic.
Sources
- Wasserstein RL, Lazar NA (2016). The ASA statement on p-values: context, process, and purpose. The American Statistician 70(2):129-133. doi:10.1080/00031305.2016.1154108
- Greenland S, Senn SJ, Rothman KJ, Carlin JB, Poole C, Goodman SN, Altman DG (2016). Statistical tests, P values, confidence intervals, and power: a guide to misinterpretations. European Journal of Epidemiology 31(4):337-350. doi:10.1007/s10654-016-0149-3
- Welch BL (1947). The generalization of 'Student's' problem when several different population variances are involved. Biometrika 34(1-2):28-35. doi:10.1093/biomet/34.1-2.28
- Kruskal WH, Wallis WA (1952). Use of ranks in one-criterion variance analysis. Journal of the American Statistical Association 47(260):583-621. doi:10.1080/01621459.1952.10483441
Methods discussed
Try it on your own data
Upload a dataset and Celeus suggests a method, checks its assumptions, runs it in R, and gives you the script to rerun it.
Try it in Celeus