Network Analysis with igraph in R: A Practical Guide
What is network analysis in R?
igraph is the go-to package for network analysis in R. When your data is shaped like a list of relationships (who follows whom, which servers talk to which, which genes regulate which), igraph turns it into a graph object you can query, measure, and plot. This guide walks through the full path: load edges from a data frame, inspect the result, compute centrality and communities, and produce a publication-ready figure.
For most everyday R users, igraph is the right default. The network and sna packages are older and aimed at social-network research workflows. tidygraph sits on top of igraph and gives you dplyr verbs over nodes and edges, which is a nice next step once you are comfortable with the base API. For interactive exploration in a Shiny app, networkD3 is the usual pick.
Install and load igraph
igraph is on CRAN. On Linux without the system libraries in place, install them first or the package will fail to compile.
# Linux only, if you don't have gmp/glpk system libraries:
# sudo apt install libgmp-dev libglpk-dev # Debian/Ubuntu
# brew install gmp glpk # macOS
install.packages("igraph")
library(igraph)
This guide uses igraph 2.x. The current stable line on CRAN is 2.2.1 (published October 2025); the development series is 2.3.x. You can confirm your installed version with packageVersion("igraph"). The 2.x line pairs the R bindings with the 0.10 series C core, so you get the same performance and feature set as the Python build. For most laptop-scale work that means graphs up to a few million edges stay responsive.
Build a graph from an edge list
The most common starting point is a data frame with one row per relationship. graph_from_data_frame() reads the first two columns as the edge endpoints; any further columns become edge attributes you can use later (weights, types, timestamps).
library(igraph)
edges <- data.frame(
from = c("Alice", "Bob", "Cecil", "Cecil", "David", "David",
"Esmeralda", "Frank", "Frank", "Gina", "Gina", "Hank",
"Hank", "Iris", "Alice", "Cecil"),
to = c("Bob", "Cecil", "Alice", "David", "Alice", "Esmeralda",
"Alice", "Bob", "Gina", "Cecil", "Hank", "Iris", "Frank",
"Frank", "Cecil", "Frank"),
weight = c(4, 5, 5, 2, 4, 3, 1, 2, 4, 3, 5, 2, 3, 1, 4, 2),
stringsAsFactors = FALSE
)
g <- graph_from_data_frame(edges, directed = TRUE)
The default for directed is TRUE, which is what you want for “follows” or “cites” data. Set it to FALSE for co-authorship, mutual trust, or other symmetric ties. If you also have a separate table of vertex metadata, pass it as the vertices argument; the first column becomes the vertex name and the rest become vertex attributes.
One easy mistake: NA in the first two columns is silently turned into the vertex literally named "NA". Drop or impute missing endpoints before you build the graph. If you are loading edges from a CSV with read.csv(), the default na.strings = "NA" will already convert the literal string "NA" to missing. Be careful if your vertex names actually contain the letters N and A.
Inspect the graph
A few one-liners tell you what you have.
vcount(g) # [1] 9
ecount(g) # [1] 16
is_directed(g) # [1] TRUE
is_weighted(g) # [1] TRUE
summary(g)
# IGRAPH DNW- 9 16 --
# + attr: name (v/c), weight (e/n)
# + edges (vertex names):
# [1] Alice->Bob Alice->Cecil Bob->Cecil ... Iris->Frank
# (16 edges total; exact ordering depends on igraph internals)
V(g) is a vertex sequence and E(g) is an edge sequence. Both are indexable and assignable, so you can attach attributes with the usual $ syntax. Use as_adjacency_matrix(g, sparse = FALSE) when you need to hand the structure to another R package that does not speak igraph. The DNW- flag at the start of the summary() line is the same formatter print(g) uses, and tells you the graph is Directed, Named, and Weighted.
Add vertex and edge attributes
Attributes are how you keep extra context on each node or edge. They show up later in centrality, layouts, and plots.
# Tag vertices with a hand-picked group
V(g)$team <- c("A", "B", "A", "B", "A", "B", "A", "B", "A")
V(g)$team
# Alice Bob Cecil David Esmeralda Frank Gina Hank Iris
# "A" "B" "A" "B" "A" "B" "A" "B" "A"
For edge attributes, you already have weight from the input data frame. You can replace or extend it after the fact with E(g)$weight <- ... or any other attribute name. Heavy dplyr users should look at tidygraph::as_tbl_graph(g) for a tidy data structure on top of igraph; the conversion keeps the existing attributes and gives you activate(nodes) and activate(edges) semantics that match a normal tibble workflow.
Compute centrality
Centrality is the most common reason to load igraph in the first place. The three you will reach for most often are degree, betweenness, and closeness. PageRank is the canonical “importance” measure for directed graphs.
# In-degree: how many edges point at each vertex
V(g)$in_deg <- degree(g, mode = "in")
V(g)$in_deg
# Alice Bob Cecil David Esmeralda Frank Gina Hank Iris
# 3 2 3 1 1 3 1 1 1
# Betweenness: how often each vertex lies on a shortest path
V(g)$betweenness <- betweenness(g, directed = TRUE)
# Closeness: 1 / mean distance to all reachable vertices
V(g)$closeness <- closeness(g, mode = "in", normalized = TRUE)
# PageRank: classic importance score for directed graphs
V(g)$pagerank <- page_rank(g)$vector
Two things trip people up here. First, the weights argument is treated as a cost, not a similarity. If your weights represent “strength of connection”, invert them first with E(g)$weight <- 1 / E(g)$weight. Second, closeness() returns NaN for vertices that cannot reach any other vertex. For disconnected graphs, swap it out for harmonic_centrality(), which sums reciprocals of distances and handles unreachable pairs gracefully. As a rule of thumb: degree tells you who has the most connections, betweenness tells you who is the broker, and PageRank tells you who would be cited by other important vertices.
Detect communities
Community detection splits the graph into groups of densely connected nodes. Louvain is the safe default: fast, undirected, and good enough for most networks.
g_undir <- as_undirected(g, mode = "collapse")
set.seed(42)
comm <- cluster_louvain(g_undir)
modularity(comm) # higher is better; 0 means random partitioning
membership(comm) # integer vector, one community id per vertex
sizes(comm) # how many vertices in each community
length(comm) # number of communities found
If Louvain splits things too aggressively, lower the resolution argument (default 1). Bump it up to find more, smaller groups. cluster_walktrap() is a slower but more deterministic alternative, and cluster_leiden() is the modern refinement of Louvain and worth the upgrade for serious work.
igraph has been non-deterministic for Louvain since 1.3 (vertices are processed in random order), so wrap community detection in set.seed() if your results need to be reproducible. Every cluster_*() function returns an igraph.community object, and the four accessors above (membership(), modularity(), sizes(), length()) work across the family.
Plot the result
Base R plotting is fine for small networks. For anything over a few hundred nodes, switch to ggraph.
set.seed(42)
V(g_undir)$community <- membership(comm)
V(g_undir)$betweenness <- betweenness(g_undir)
plot(g_undir,
layout = layout_with_fr,
vertex.color = rainbow(length(comm))[membership(comm)],
vertex.size = 8 + sqrt(V(g_undir)$betweenness),
vertex.label = V(g_undir)$name,
vertex.label.cex = 0.8,
edge.width = E(g_undir)$weight / 2,
edge.color = adjustcolor("grey40", alpha = 0.6),
main = "Toy network, colored by Louvain community")
mark.groups is the cleanest way to draw a translucent hull around each community; pass it a list of integer vertex IDs. For the layout algorithm, layout_with_fr (Fruchterman-Reingold) is the safe default, layout_with_kk (Kamada-Kawai) works well on smaller graphs, and layout_in_circle is good for showing partitions on a small network. Note that the layout argument can be either a coordinate matrix (the result of calling a layout function) or a function name (without parentheses), and the plotting code handles both forms.
To save a figure, wrap the plot() call in png() or pdf():
png("network.png", width = 1200, height = 900, res = 150)
plot(g_undir, layout = layout_with_fr,
vertex.color = rainbow(length(comm))[membership(comm)])
dev.off()
PDF is usually the right pick for vector output that scales cleanly into a report.
Common mistakes
- Treating
weightsas a similarity. igraph uses edge weights as distances. Invert them first if your data is “how strong”. - Running Louvain on a directed graph.
cluster_louvain()requires an undirected input. Callas_undirected()first. - Letting
closeness()returnNaN. On disconnected graphs, preferharmonic_centrality(). - Forgetting
set.seed()around Louvain. It is non-deterministic since igraph 1.3, so reruns drift. - Letting
NAslip into the edge list. igraph turns it into a vertex literally named"NA". - Plotting a 5,000-node graph with base R. The base plotter uses base R drawing primitives and grinds to a halt past a few hundred edges. Use ggraph or visNetwork instead.
Frequently asked questions
How do I handle weighted edges correctly?
igraph treats the weight edge attribute as a distance, not a similarity. If your data records “strength of connection”, invert the values with E(g)$weight <- 1 / E(g)$weight before passing the graph to betweenness(), closeness(), or any of the shortest-path functions. The cluster_*() family also consumes weights, and Louvain and Leiden both treat higher weight as a stronger tie, so the inversion is usually the right call there too.
Can I run community detection on a directed graph?
Most algorithms in the cluster_* family expect an undirected input. Louvain and Leiden will either error or produce surprising results if you hand them a directed graph. The standard fix is to convert first with as_undirected(g, mode = "collapse") before passing it to cluster_louvain() or cluster_leiden(). The “collapse” mode combines parallel edges, which is usually what you want for symmetric-relationship data.
What is the difference between cluster_louvain() and cluster_leiden()?
Both optimise modularity. Louvain is the older, widely-deployed algorithm and runs fast on small graphs. Leiden is the modern refinement with better handling of disconnected components and a stronger guarantee about the quality of the partition it returns. For a one-off analysis on a few thousand vertices, Louvain is fine. For repeat analyses on larger networks, Leiden is the better default.
Why is my closeness() result full of NaN?
closeness() requires every vertex to reach every other vertex. In a disconnected graph the unreachable pairs return NaN and propagate. The fix is harmonic_centrality(), which sums reciprocals of distances and handles unreachable pairs gracefully. The two measures are close on dense graphs and diverge sharply on sparse or fragmented ones.
Should I use the base plot or ggraph for a real network?
Base plot.igraph() is fine up to a few hundred nodes. Past that, the base plotter drags because it issues a separate drawing primitive per element. Switch to ggraph (works with ggplot2 scales and faceting) or visNetwork (interactive HTML) once you cross the ~1k-edge mark. For a one-page static figure in a report, base plot and a PDF device still give the cleanest result.
Conclusion
Network analysis in R is the workflow igraph was built for: edge lists in, graphs out, plus the centrality, community, and plotting primitives you need to make sense of a connected dataset. Once you are comfortable with the base API, two natural follow-ups are tidygraph for dplyr-style manipulation of nodes and edges, and ggraph for ggplot2-style network plots that scale to thousands of nodes.
See also
- Dplyr data wrangling — for shaping the edge list before you hand it to igraph.
- How to read a CSV file — most networks start as a CSV of relationships.
- Data frame reference — what
graph_from_data_frame()actually expects.