The LifterLMS Database Explained: Post Types, Meta and Custom Tables

A developer’s map of the LifterLMS database: which post types hold courses, orders and certificates, where enrollments and progress live, what each custom table stores, and how to query it safely.

  • Add as a preferred source on Google
Illustration for “The LifterLMS Database Explained: Post Types, Meta and Custom Tables”

Sooner or later every LifterLMS developer needs to know where the data actually lives. You might be building a custom report, migrating students in from another platform, debugging an enrollment that looks wrong, or deciding which tables to index on a busy site.

This article maps the LifterLMS database as it stands in version 10.3: the post types that hold content and commerce, the meta keys that link them, the custom tables that hold enrollments, progress and quiz attempts, and the user meta used for caching. It ends with how to query that data without fighting the plugin.

Table names below use the default wp_ prefix. Yours may differ. Everything here was checked against the plugin source; still, treat direct database access as a last resort and prefer the PHP API where one exists.

In short, LifterLMS stores data in three places:

  • WordPress posts and post meta for anything that is a "thing": courses, lessons, quizzes, memberships, access plans, orders, transactions, certificates and engagements.
  • Custom tables for high-volume activity: enrollments and completions, quiz attempts, notifications, events, sessions, vouchers and lesson time tracking.
  • User meta and options for per-student cached values, membership restrictions and settings.

Model classes wrap all of this. LLMS_Course, LLMS_Lesson, LLMS_Order, LLMS_Student and the rest read and write the right rows for you, which is why the PHP API is almost always the better entry point.

Post types for content and commerce

Post typeHolds
courseCourses
sectionSections inside a course
lessonLessons
llms_quizQuizzes
llms_questionQuiz questions
llms_membershipMemberships
llms_access_planPricing options attached to a course or membership
llms_orderOrders
llms_transactionIndividual payments and refunds against an order
llms_coupon, llms_voucherCoupons and vouchers
llms_engagementEngagement rules (trigger, type, delay)
llms_emailEngagement email templates
llms_achievement, llms_my_achievementAchievement templates and awarded achievements
llms_certificate, llms_my_certificateCertificate templates and awarded certificates
llms_reviewCourse reviews

Courses use four taxonomies: course_cat, course_tag, course_difficulty and course_track. Memberships have their own category and tag taxonomies.

How the hierarchy is linked

LifterLMS does not use post_parent for the course outline. It uses post meta, and every meta key a model manages is prefixed with _llms_. So the parent_course property of a section is stored as _llms_parent_course.

  • A section stores _llms_parent_course.
  • A lesson stores _llms_parent_section, _llms_parent_course and _llms_order (its position), plus _llms_quiz when it has a quiz.
  • A quiz stores _llms_lesson_id; a question stores _llms_parent_id pointing to its quiz.
  • An access plan stores _llms_product_id, the course or membership it sells.

Because the same relationship is stored on both sides in places, moving lessons with raw SQL is risky. Use LLMS_Lesson::set() or the Course Builder so both sides stay in sync.

Orders and transactions

An llms_order post is the agreement: who bought which access plan, at what price, with which gateway. Its post status is the order status, such as llms-pending, llms-active, llms-completed, llms-on-hold, llms-pending-cancel, llms-cancelled, llms-expired, llms-refunded or llms-failed.

Order details live in meta, again with the _llms_ prefix: user_id, product_id, plan_id, total, currency, payment_gateway, order_key, billing fields, trial fields, date_next_payment, date_access_expires and gateway references such as gateway_customer_id and gateway_subscription_id. Order notes are stored as WordPress comments on the order.

Each payment or refund is an llms_transaction post with its own statuses: llms-txn-succeeded, llms-txn-pending, llms-txn-failed and llms-txn-refunded. Its _llms_order_id meta links it to the order. When a transaction succeeds, LifterLMS moves the order to active (recurring) or completed (one-time) and enrollment follows. Our article on building a custom payment gateway walks through that flow in code.

Enrollments and progress: wp_lifterlms_user_postmeta

The most important custom table is wp_lifterlms_user_postmeta. Each row joins a user to a post with a key, a value and an updated_date. It has indexes on user_id and post_id.

meta_keyValueMeaning
_statusenrolled, expired, cancelledEnrollment status in a course or membership
_start_dateyesIts updated_date is the enrollment date
_enrollment_triggerFor example order_123What caused the enrollment
_is_completeyes or noCompletion of a lesson, section, course or track
_completion_triggerTextWhat marked it complete
_achievement_earned, _certificate_earnedID of the awarded postLinks an awarded engagement to the student and related post
_email_sentID of the emailRecord of a sent engagement email

Two behaviours matter when you query it:

  • Enrollment rows are a history, not a single value. Enrolling and unenrolling add new _status rows rather than replacing the old one. The current status is the newest row, which is how LLMS_Student::get_enrollment_status() reads it: ordered by updated_date and then meta_id, descending.
  • The enrollment date comes from updated_date. Drip schedules based on days after enrollment use it, so migrations that create enrollments must set it correctly.

The other custom tables

TableStores
wp_lifterlms_quiz_attemptsOne row per attempt: student, quiz, lesson, start and end dates, status, attempt number, grade, and the questions and answers as serialized data
wp_lifterlms_lesson_time_sessionsLesson time tracking (added in 10.1): heartbeat-based sessions per user and lesson with accumulated seconds
wp_lifterlms_eventsActivity events such as sign-on, sign-out, session start and end, and page load, exit, focus and blur on course content
wp_lifterlms_events_open_sessionsPointers to sessions that are still open
wp_lifterlms_notificationsQueued and sent notifications: type, subscriber, status, trigger, user and post
wp_lifterlms_sessionsFront-end session data such as checkout notices, with an expiry
wp_lifterlms_vouchers_codes, wp_lifterlms_voucher_code_redemptions, wp_lifterlms_product_to_voucherVoucher codes, redemptions and the products each voucher unlocks

The events and notifications tables grow with activity. On large sites they are usually the first tables worth watching, which our guide to scaling LifterLMS covers.

User meta, options and cached values

LLMS_Student stores its own values in user meta with an llms_ prefix. Progress is cached this way: get_progress() saves results under keys such as llms_course_456_progress. The Student Progress Cache tool under LifterLMS > Status > Tools & Utilities clears the overall progress and grade caches (llms_overall_progress and llms_overall_grade); they are rebuilt on the next read and do not change real progress.

Membership restrictions are held in _llms_restricted_levels user meta. Aggregate course data, such as _llms_average_progress, _llms_average_grade and _llms_enrolled_students, is calculated by a background processor and saved on the course. Settings live in the options table with lifterlms_ and llms_ prefixes; payment gateway settings use llms_gateway_{id}_, and permalink bases are in llms_permalinks.

Querying the data the right way

Use the models first. To list students enrolled in a course with their progress and enrollment date:

$course_id = 456;

$query = new LLMS_Student_Query(
	array(
		'post_id'  => $course_id,
		'statuses' => array( 'enrolled' ),
		'per_page' => 100,
		'page'     => 1,
	)
);

foreach ( $query->get_students() as $student ) {
	printf(
		"%s,%s,%s\n",
		$student->get( 'user_email' ),
		$student->get_enrollment_date( $course_id, 'enrolled', 'Y-m-d' ),
		$student->get_progress( $course_id, 'course' )
	);
}

For reporting across thousands of students, a direct, read-only query can be far faster. This one counts course completions per course in the last 30 days:

SELECT upm.post_id AS course_id, COUNT(*) AS completions
FROM wp_lifterlms_user_postmeta AS upm
INNER JOIN wp_posts AS p ON p.ID = upm.post_id AND p.post_type = 'course'
WHERE upm.meta_key = '_is_complete'
  AND upm.meta_value = 'yes'
  AND upm.updated_date >= DATE_SUB( NOW(), INTERVAL 30 DAY )
GROUP BY upm.post_id;

In PHP, run queries like this through $wpdb->prepare() with $wpdb->prefix rather than a hard-coded prefix. The LifterLMS Developer Hub documents the model classes in its code reference, and the REST API under /wp-json/llms/v1 exposes students, enrollments, progress and grades for external systems; see our REST API and webhooks introduction.

Common mistakes with LifterLMS data

  • Writing rows directly. Inserting an _status row skips the hooks that send emails, award engagements, update caches and fire webhooks. Use llms_enroll_student(), llms_unenroll_student() and llms_mark_complete().
  • Reading the first status row. Always take the newest row for a user and post.
  • Forgetting caches. After manual database changes, flush the object cache and clear the student progress cache, or students see stale progress.
  • Deleting posts with raw SQL. Orphaned lessons, plans and user postmeta rows confuse reports. Delete through WordPress so LifterLMS can clean up related data.

Frequently asked questions

Where does LifterLMS store which courses a student is enrolled in?

In wp_lifterlms_user_postmeta, as _status rows. The newest row for each user and course is the current status.

Are LifterLMS orders WooCommerce orders?

No. LifterLMS has its own llms_order and llms_transaction post types. Sites that sell through the WooCommerce integration add-on have WooCommerce orders as well.

Does LifterLMS keep quiz answers?

Yes. Each attempt row in wp_lifterlms_quiz_attempts stores the questions, the answers given and the grade.

Next steps

Before writing any custom query, check whether a model method or REST route already returns what you need. If it does not, query read-only, prepare every value, and test against a copy of production data.

If you are planning a custom report, a data migration or a performance fix that touches these tables, our LifterLMS development team works with this schema every day. Tell us what you are building and we will suggest the safest approach.

Add LifterLMS Expert to your preferred sources

Add lifterlmsexpert.com as a preferred source on Google, or open this article in your AI assistant to use it as a source.

  • Add as a preferred source on Google
Free consultation

Prefer expert help over DIY?

Skip the trial and error. Get specialist LifterLMS help and ship faster.

Talk to a LifterLMS Expert Explore our services

Replies within one business day