Skip to content

Database schema

The plugin owns one custom table plus a small set of options, user metas, transients, and cookies. This page documents every storage location.

Created on activation via dbDelta(). The DDL that ships in class-bp-profile-views-activator.php:

CREATE TABLE {prefix}bp_profile_views (
id mediumint(9) NOT NULL AUTO_INCREMENT,
user_id int(10) NOT NULL,
viewer_id int(10) NOT NULL,
created datetime DEFAULT current_timestamp() NOT NULL,
PRIMARY KEY (id),
KEY user_created (user_id, created),
KEY created (created)
);
Column Type Notes
id mediumint(9) AUTO_INCREMENT PRIMARY KEY Row identifier.
user_id int(10) Profile owner (the person being viewed).
viewer_id int(10) Visitor. 0 for logged-out visitors.
created datetime DEFAULT current_timestamp() Server time at insert; never updated.

Before 1.5.3 the table shipped with only PRIMARY KEY (id), so every aggregate the plugin runs was a full table scan. Two indexes were added:

Index Covers
user_created (user_id, created) Every query that filters by profile owner, which is nearly all of them — the Views-tab chart, the directory badge, the admin leaderboard, and the Recent Visitor widget. The created half also serves ORDER BY created DESC, so the widget gets a backward index scan instead of a filesort.
created (created) The time-window-only queries that never name a user — the Popular User widget and the Views Today stat.

viewer_id is deliberately not indexed. The only predicate on it is viewer_id != 0, which is far too low-cardinality to help.

Existing installs are migrated automatically. bp_profile_views_ensure_indexes() runs on plugins_loaded, reads INFORMATION_SCHEMA first so it only adds what is missing, and records completion in the bp_profile_views_indexes_v1 option. It is idempotent — safe to run repeatedly — and only records completion once every index exists, so a failed ALTER is retried rather than skipped. Both ALTERs are INPLACE-eligible on InnoDB, so they do not lock reads or writes on a large table.

To confirm the migration ran on your site:

SHOW INDEX FROM wp_bp_profile_views;
-- expect: PRIMARY, user_created, created

There is no foreign-key constraint to wp_users, by design. Deleting a WordPress user does not cascade. Orphan-row cleanup is your responsibility (see Useful queries below).

Key Type Purpose
bp_profile_views_general_options serialized array Main settings blob (see below).
bp_profile_views_db_version string Stored schema version; used for future upgrade checks.
bp_profile_views_visitor_setting_seeded flag Set to '1' after the one-time visitor_setting meta seed runs.
edd_wbcom_BPPV_license_key string EDD license key.
edd_wbcom_BPPV_license_status string EDD license status (valid, invalid, etc.).
edd_wbcom_BPPV_license_expires string License expiry date string from EDD.

The options bp_profile_views_admin_welcome_options, bp_profile_views_members_options, and bp_profile_views_support_options are legacy stubs from 1.4.x that never held real data. Their registration was dropped in 1.5.0, and the stale wp_options rows are deleted automatically on upgrade to 1.5.3 (and on uninstall).

Key Default Controls
save_count_by 'session' Dedupe strategy: 'session' or 'referer'.
chart_style 'line' Chart.js render mode on the Views tab ('line', 'bar', 'polarArea').
view_member_count 10 Default row limit for widget/list displays.
show_in_directory 'no' Show a view-count badge on members directory cards when 'yes'.
show_recent_members 'no' Render the recent-visitors avatar strip in the profile header when 'yes'.
allow_user_settings 'no' Let members toggle their own tracking opt-out when 'yes'.
exclude_logout_user_count 'yes' Filter out viewer_id = 0 rows from aggregate queries when 'yes'.
show_inside_header_meta (absent) If 'yes', renders the strip on bp_profile_header_meta; otherwise theme-specific hook.
allow_user_notification (absent) Fire a BP notification on each qualifying view when 'yes'.
max_display_in_header 5 Maximum avatar count in the recent-visitors strip.
default_avatar_size 32 Avatar pixel size in the strip.
Key Purpose
visitor_setting Per-user opt-out: 'yes' = allow tracking, 'no' = skip. Seeded once on activation.
bp_profile_views_user_login_session Array of profile_{user_id}_{session_token} strings used to dedupe views within a single login session.
Key Type TTL Purpose
bp_profile_views_referer_transient transient 6 hours Stores HTTP_REFERER for save_count_by = 'referer' dedupe.
bp_profile_visit transient 6 hours Logged-out visitor dedupe.
edd_wbcom_BPPV_license_key_data transient 24 hours Cached license metadata from the EDD API.
BP_PROFILE_VIEW_REQUEST_URI cookie session Last request URI; used only in 'referer' mode.
BP_IS_USER cookie session Whether the prior request was a user-profile page; used only in 'referer' mode.

The cookies and the bp_profile_views_referer_transient transient are written only when save_count_by = 'referer'. In the default 'session' mode, none of the above cookie or referer-transient writes happen.

global $wpdb;
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT user_id, COUNT(*) AS total
FROM {$wpdb->prefix}bp_profile_views
WHERE viewer_id != 0
GROUP BY user_id
ORDER BY total DESC
LIMIT %d",
100
)
);
$viewer_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT viewer_id
FROM {$wpdb->prefix}bp_profile_views
WHERE user_id = %d AND viewer_id != 0
ORDER BY id DESC
LIMIT %d",
$user_id,
$limit
)
);

Cleanup orphans (users that no longer exist)

Section titled “Cleanup orphans (users that no longer exist)”
DELETE pv FROM {prefix}bp_profile_views pv
LEFT JOIN {prefix}users u ON u.ID = pv.user_id
WHERE u.ID IS NULL;
Terminal window
wp db query "DELETE FROM \`$(wp db prefix)bp_profile_views\` WHERE created < DATE_SUB( NOW(), INTERVAL 1 YEAR );"

Since 1.5.3 the two indexes the plugin’s own queries need ship by default — see Indexes above. You should not need to add anything for normal operation.

Measured on a seeded install, the admin leaderboard query went from a full table scan to an index range scan:

before: type=ALL key=NULL rows=71
after: type=range key=user_created rows=8

Views Today became a covering index scan (no table access at all), and the Recent Visitor widget now uses a backward index scan instead of sorting in memory.

If you are still seeing slow queries, check these before adding indexes:

  1. Confirm the migration ran. SHOW INDEX FROM {prefix}bp_profile_views; should list user_created and created. If it does not, see Indexes.
  2. Check your object cache. The leaderboard and the Overview stat cards are cached for one minute. Without a persistent object cache (Redis / Memcached) that cache is per-request only, so every page load recomputes them.
  3. Consider trimming history. A view row is only useful for as long as you report on it — see Trim old rows above.

Only add further indexes if you have a specific slow query and an EXPLAIN that shows the existing indexes are not being used. Each index adds write overhead on every profile view, and this table is write-heavy by nature.

One case that is genuinely not covered: if you query by viewer rather than by profile owner (for example, “which profiles has member X visited?”), no shipped index serves that. Add one only if you have built such a report:

CREATE INDEX idx_bpv_viewer ON {prefix}bp_profile_views (viewer_id, created);