Written and maintained by CASRAI Editorial Board
Last updated
Base R’s plot(x, y) draws a scatterplot from two numeric vectors with zero setup and silently drops any row with a missing value; ggplot2::geom_point() is the layered-grammar alternative, needed the moment you want to map color or shape to a grouping variable, control overplotting with transparency, or add a fitted trend line with a documented method. Both are covered here as one page because they answer the same practical question — “how do these two variables relate, and how do I show that clearly” — with genuinely different mechanics, and one very different failure mode around missing data.
Building the same chart in another package? See SPSS or Stata. Plotting a single variable’s distribution instead of a relationship between two? See CASRAI’s Histograms in R guide for hist() and geom_histogram().
Every code block below was run in R 4.6.1 with ggplot2 4.0.3, using the built-in airquality dataset (New York air quality measurements, daily, May–September 1973) — the same dataset used in CASRAI’s histogram guide, chosen again here because its Ozone column contains 37 missing values out of 153 rows, which is exactly what exposes the biggest pitfall below. Output blocks are the actual console result, not a stylised approximation.
plot(): the one-line base R scatterplot
plot() needs only two numeric vectors, x then y:
plot(airquality$Wind, airquality$Ozone,
xlab = "Wind (mph)", ylab = "Ozone (ppb)",
main = "Ozone vs. Wind")
The plot renders with no console output and, importantly, no warning — even though Ozone has 37 NA values. plot() simply omits any row where either coordinate is missing, the same silent-drop behavior CASRAI’s Histograms in R guide documents for hist(). If you’re reporting how many observations a figure is actually based on, plot() will not tell you; check it yourself:
sum(complete.cases(airquality[, c("Wind", "Ozone")]))
#> [1] 116
nrow(airquality)
#> [1] 153
116 of 153 rows had both coordinates present — the other 37 are on the plot’s underlying data but invisible in its output, with nothing in the console record to say so.
Styling and grouping with pch and col
Base R controls point shape with pch (an integer code, 0–25) and point color with col. Both accept a single value or a vector recycled across points, which is how you map a grouping variable without any special “aesthetic” syntax:
plot(airquality$Wind, airquality$Ozone,
pch = 19,
col = as.factor(airquality$Month))
legend("topright", legend = levels(as.factor(airquality$Month)),
col = 1:5, pch = 19, title = "Month")
as.factor(airquality$Month) converts the integer month codes (5–9) to a factor; when a factor is passed to col, R uses its internal integer levels (1, 2, 3…) against the default palette. This is fully manual — there’s no automatic legend, which is why the legend() call is a second, separate step with its own col/pch values that you’re responsible for keeping in sync with the plot call.
Adding a fitted line in base R
abline() draws a line from a fitted model object directly onto an existing plot:
plot(airquality$Wind, airquality$Ozone)
fit <- lm(Ozone ~ Wind, data = airquality)
abline(fit, col = "red", lwd = 2)
coef(fit)
#> (Intercept) Wind
#> 96.872895 -5.550923
lm() itself drops incomplete rows automatically (via its default na.action = na.omit) to fit the model, independent of whatever plot() did or didn’t show — the two calls handle missing data separately, which is worth knowing if you’re cross-checking a reported slope against a figure.
ggplot2’s geom_point()
The grammar-of-graphics equivalent needs a data frame and an aes() mapping:
library(ggplot2)
ggplot(airquality, aes(x = Wind, y = Ozone)) +
geom_point()
Unlike plot(), this does not fail silently on the missing Ozone values — it prints a warning to the console every time:
#> Warning message:
#> Removed 37 rows containing missing values or values outside the scale range
#> (`geom_point()`).
That’s the single clearest practical difference between the two systems for research use: base R’s silent drop can hide a real data-completeness problem from anyone reading the console log or a script’s output; ggplot2’s warning surfaces the same drop automatically, unprompted, every time the plot is built — useful if a lab or thesis committee expects the console record to show data-quality issues rather than bury them.
Mapping color and shape to a grouping variable
Where base R needed manual factor-to-integer conversion and a hand-built legend, ggplot2 handles both from one mapping:
ggplot(airquality, aes(x = Wind, y = Ozone, color = as.factor(Month))) +
geom_point(size = 2) +
labs(color = "Month")
The legend is generated automatically from the mapped variable’s levels — there is no separate step to keep in sync with the plot, which is the main practical advantage over the base R pch/col/legend() approach above once more than one grouping variable is involved. Shape can be mapped the same way (shape = as.factor(Month)), and both can be combined in one plot for two simultaneous groupings.
Overplotting: alpha and jitter
When many points share close or identical coordinates, a scatterplot understates how many observations are actually there. Two independent fixes, often used together:
alpha— makes points semi-transparent, so overlapping points render visibly darker than isolated ones:geom_point(alpha = 0.4).geom_jitter()(orposition = "jitter"insidegeom_point()) — adds a small random offset to each point’s coordinates, which separates points that would otherwise land exactly on top of each other. Only appropriate when the small positional noise doesn’t misrepresent the underlying data (e.g. a variable that’s genuinely continuous, not a rounded or discretized one where the jitter could be read as false precision).
ggplot(airquality, aes(x = Wind, y = Ozone)) +
geom_jitter(width = 0.3, alpha = 0.5)
Adding a trend line with geom_smooth()
geom_smooth() layers a fitted curve directly onto the points, and — like plot()‘s data-completeness silence above — its default method is easy to apply without noticing what it chose:
ggplot(airquality, aes(x = Wind, y = Ozone)) +
geom_point() +
geom_smooth()
#> `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
#> Warning messages:
#> 1: Removed 37 rows containing non-finite outside the scale range
#> (`stat_smooth()`).
#> 2: Removed 37 rows containing missing values or values outside the scale range
#> (`geom_point()`).
With no method argument, geom_smooth() defaults to a loess curve (locally weighted, non-linear) and prints which formula it used — a message worth reading, not just dismissing, since a loess curve and a straight regression line can tell visibly different stories about the same data. For a straight OLS line matching the abline(lm(...)) result above, specify the method explicitly:
ggplot(airquality, aes(x = Wind, y = Ozone)) +
geom_point() +
geom_smooth(method = "lm")
#> `geom_smooth()` using formula = 'y ~ x'
Both layers — stat_smooth() fitting the line and geom_point() drawing the points — independently report the same 37 dropped rows, which is why the earlier warning appeared twice once a smoother was added.
Checking the relationship numerically, not just visually
A scatterplot suggests a relationship; cor() quantifies it — but with the same missing-data trap as plot(), expressed differently:
cor(airquality$Wind, airquality$Ozone)
#> [1] NA
cor()‘s default use = "everything" returns a flat NA the instant either vector contains one, with no warning explaining why. The fix is the same use argument documented for other CASRAI R guides:
cor(airquality$Wind, airquality$Ozone, use = "complete.obs")
#> [1] -0.6015465
A moderate negative correlation — consistent with the downward-sloping fitted line both abline(lm(...)) and geom_smooth(method = "lm") drew above.
Common pitfalls
plot()never tells you it dropped rows;geom_point()andgeom_smooth()always do. If a figure needs to report exactly how many observations it’s based on, don’t rely on either function’s console output for that number — compute it directly withcomplete.cases(), as shown above.cor()silently returnsNAon any missing data unlessuseis set explicitly — a script that prints a correlation and moves on without checking forNAcan fail downstream in a confusing way far from the actual cause.- A numeric grouping column plotted without converting to a factor (e.g.
color = Monthinstead ofcolor = as.factor(Month)) produces a continuous color gradient instead of discrete group colors in ggplot2, and in base R’scolargument can produce an unintended color sequence — check whether the variable is meant to be discrete before mapping it. geom_smooth()‘s default method is loess, not a straight line. Reporting “the trend line” from a defaultgeom_smooth()call without checking the console message risks describing a curved loess fit as if it were a linear regression result.
Frequently asked questions
How do I add a straight regression line to a ggplot2 scatterplot?
Use geom_smooth(method = "lm"). Without method specified, geom_smooth() defaults to a non-linear loess curve instead, which is a different fitted shape.
Why does my R scatterplot have fewer points than rows in my data?
Both plot() and geom_point() drop any row with a missing x or y value before plotting. plot() does this with no console message; geom_point() reports the exact count removed. Check complete.cases() on the relevant columns if you need the true point count.
How do I color points by group in a scatterplot in R?
In ggplot2, map the grouping variable to color inside aes() (converting it to a factor first if it’s stored as a number): aes(x, y, color = as.factor(group)). In base R, pass col = as.factor(group) to plot() and build a matching legend manually with legend().
What does “removed rows containing missing values” mean in ggplot2?
It’s geom_point() (or another layer, such as stat_smooth()) reporting how many rows it excluded because at least one plotted variable was NA or non-finite for that row. It’s informational, not an error — but it’s worth reading rather than dismissing, since it’s telling you the plot represents fewer observations than the full dataset.
How do I fix overlapping points in an R scatterplot?
Add transparency with alpha (e.g. geom_point(alpha = 0.4)) so overlapping points render darker, and/or use geom_jitter() to add a small random offset that separates points sharing near-identical coordinates.








