rguides

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 weights as 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. Call as_undirected() first.
  • Letting closeness() return NaN. On disconnected graphs, prefer harmonic_centrality().
  • Forgetting set.seed() around Louvain. It is non-deterministic since igraph 1.3, so reruns drift.
  • Letting NA slip 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