Skip to main content
v2026.11,772 entries · CC-BY 4.0

Scatterplots in R: plot() and ggplot2’s geom_point()

How to build scatterplots in R with base plot() and ggplot2’s geom_point() — grouping by color/shape, fixing overplotting, adding loess/lm trend lines with geom_smooth(), and the silent-NA-drop pitfall that catches both methods differently.

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() (or position = "jitter" inside geom_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() and geom_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 with complete.cases(), as shown above.
  • cor() silently returns NA on any missing data unless use is set explicitly — a script that prints a correlation and moves on without checking for NA can fail downstream in a confusing way far from the actual cause.
  • A numeric grouping column plotted without converting to a factor (e.g. color = Month instead of color = as.factor(Month)) produces a continuous color gradient instead of discrete group colors in ggplot2, and in base R’s col argument 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 default geom_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.

Follow CASRAI

Research-administration guidance, standards updates and independent tool reviews.

Ask CASRAI · included with Regulatory Radar

Ask about Scatterplots in R: plot() and ggplot2’s geom_point()

Ask CASRAI answers research-administration questions and cites the passages behind every claim — and says so when the corpus does not cover something, instead of guessing. It comes with a Regulatory Radar subscription at $29 a month, alongside the daily digest of regulatory changes and the dashboard of what changed.

150 questions a day, on this site, over the API, or inside your own tools through the CASRAI MCP server.

Everything CASRAI publishes — this page, the dictionary, the guides and the news — stays free to read, with no account and no card.

Referenced across the research world

University of Cambridge logoColumbia University logoCrossref logoUniversity of Edinburgh logoHarvard University logoUniversity of Oxford logoPrinceton University logoStanford School of Medicine logoUniversity College London logoORCID logoUniversity of Cambridge logoColumbia University logoCrossref logoUniversity of Edinburgh logoHarvard University logoUniversity of Oxford logoPrinceton University logoStanford School of Medicine logoUniversity College London logoORCID logo
  • University of Cambridge logo
  • Columbia University logo
  • Crossref logo
  • University of Edinburgh logo
  • Harvard University logo
  • University of Oxford logo
  • Princeton University logo
  • Stanford School of Medicine logo
  • University College London logo
  • ORCID logo

View CASRAI adoption →

Regulatory Radar

Stop finding out after the fact

$29/month, cancel anytime. Daily digest updates from our analysis, a dashboard holding the same items, and a cited assistant for everything they raise.

  • Federal Register, Federal Register+, Grants.gov, Regulations.gov, NSF News, UKRI, plus CASRAI’s own published content.
  • 72,264 indexed passages, and every answer cites the ones it drew on.