フィットネス・健康

ジム・スタジオの「稼働率」を、予約と会員管理のデータから出す。枠・トレーナー・会員の3つの定義、休講・取り消し・無断欠席・体験の入れ方と、同じデータで数字が変わる例

ジムやスタジオの稼働率を、予約と会員管理のデータから出す方法です。クラスの枠・トレーナー・会員の3つの定義と、休講・取り消し・無断欠席・体験の入れ方を決め、同じデータでも定義で数字が変わる例をSQLで確かめました。

この記事の結論:ジムやスタジオの「稼働率」は、枠・トレーナー・会員の3つに分け、それぞれ分母と分子に入れるものを先に決めます。①クラスやパーソナルの枠がどれだけ埋まったか、②トレーナーの勤務の時間のうちどれだけ指導に使われたか、③在籍している会員のうち何人が実際に来たか、の3つです。とくに決めておくのは、休講の枠、期限内と期限後の取り消し、無断欠席、体験の4つの扱いです。同じ1週間の仮のデータで数えると、決め方の違いだけで、枠の予約の稼働率は52.1%・54.0%・63.3%に分かれました。定義を決めて集計のSQLに書いておけば、毎月同じ物差しで比べられます。

この記事の内容のご相談は株式会社bundlyzeへbundlyzeのHP・LP制作について見てみる

この記事は、パーソナルジム・ヨガやピラティスのスタジオ・24時間ジムなどのオーナーや店長の方と、その予約や会員管理の仕組みを作る開発の方に向けています。「稼働率を上げたい」と考えたとき、まず数字の定義がそろっていないと、時間割を変えた効果も、トレーナーを増やす判断も比べられません。売上や会員数の月次レポート全体の作り方はジムの月次レポートを自動で作る記事で扱ったので、ここでは「稼働率」の定義と出し方に絞ります。コードとテストは2026年10月6日に、手元のMacで実際に動かしています。数字はすべて、説明のために作った仮のデータのもので、どこかのジムの実績ではありません。

「稼働率」は3つに分けて、何を知りたいかで使い分ける

稼働率という言葉には、決まった1つの定義がありません。話す人によって、クラスの埋まり具合のことだったり、トレーナーの忙しさのことだったり、会員がどれだけ来ているかのことだったりします。この記事では、次の3つに名前を分けました。

名前 分子 ÷ 分母 分かること 使う場面の例
枠の稼働率 予約(または来館)の人数 ÷ 定員の合計 時間割のどこが埋まり、どこが空いているか クラスの時間帯や回数の見直し
トレーナーの稼働率 来館のあったパーソナルの枠の時間 ÷ 勤務の時間 トレーナーの勤務のうち、指導に使われた割合 シフトの組み方、採用の判断
会員の利用率 期間内に1回以上来館した会員 ÷ 期間の終わりに在籍していた会員 在籍しているのに来ていない会員がどれだけいるか 退会の予防の声かけ

枠の稼働率は、さらに「予約で埋まった割合」と「実際に来た割合」に分けて見ます。予約は入っているのに来館が少ない枠は、時間割ではなく、取り消しや無断欠席の決まりのほうを見直す対象です。取り消しや無断欠席のルールの決め方は、ジムの予約のルールの記事にまとめています。

分母と分子に入れるもの・外すものを、先に表にする

数字がぶれる原因のほとんどは、境目の扱いです。集計を作る前に、次の表を社内で決めて書き残します。この記事のSQLは、この表のとおりに作っています。

境目 この記事での扱い 理由
休講の枠 分母から外す(休講の数は別に数える) 店の都合で開かなかった枠を「埋まらなかった」と数えないため
予約が0件の枠 分母に入れる 開いたのに埋まらなかった枠こそ、知りたい数字のため
期限内の取り消し 予約にも来館にも数えない ほかの人が予約できた枠のため
期限を過ぎた取り消し・無断欠席 予約には数え、来館には数えない ほかの人が予約できなかった枠のため
体験 枠の稼働率には含め、会員の数字(無断欠席の率・会員の利用率)とは分ける 枠は埋めているが、会員の行動とは別に見たいため
期間の終わりの日 含めない(11月なら 11/1 以上・12/1 未満) 月をまたぐ集計で、同じ日を2回数えないため

この表は1つの決め方の例で、正解ではありません。たとえば体験を枠の稼働率から外したい店もあります。大切なのは、決めた扱いを書き残し、毎月同じ扱いで数えることです。

予約と会員のデータは、最小で4つの表にする

集計に使うのは、枠・予約・勤務・会員の4つの表です。予約1件を1行にし、取り消しや無断欠席を、行を消さずに状態として残しておくのが要です。取り消した予約を消してしまうと、期限内だったか期限後だったかが分からなくなります。

schema.mjsjs
// ジム・スタジオの「稼働率」を出すための最小のテーブル(node:sqlite)
import { DatabaseSync } from 'node:sqlite';
export function open(file = ':memory:') {
  const db = new DatabaseSync(file);
  db.exec(`
  CREATE TABLE IF NOT EXISTS slots (          -- クラス・パーソナルの枠
    id INTEGER PRIMARY KEY,
    starts_at TEXT NOT NULL,                  -- 日本時間 'YYYY-MM-DD HH:MM'
    minutes INTEGER NOT NULL,
    kind TEXT NOT NULL CHECK (kind IN ('class','personal')),
    trainer TEXT NOT NULL,
    capacity INTEGER NOT NULL CHECK (capacity > 0),
    status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','cancelled'))  -- cancelled=休講
  );
  CREATE TABLE IF NOT EXISTS bookings (       -- 予約1件=1行
    id INTEGER PRIMARY KEY,
    slot_id INTEGER NOT NULL REFERENCES slots(id),
    member_id TEXT NOT NULL,
    is_trial INTEGER NOT NULL DEFAULT 0,      -- 体験の予約
    status TEXT NOT NULL CHECK (status IN ('booked','cancelled','late_cancel','no_show','attended'))
    -- cancelled=期限内の取り消し / late_cancel=期限を過ぎた取り消し / no_show=無断欠席 / attended=来館
  );
  CREATE TABLE IF NOT EXISTS shifts (         -- トレーナーの勤務
    trainer TEXT NOT NULL, starts_at TEXT NOT NULL, minutes INTEGER NOT NULL
  );
  CREATE TABLE IF NOT EXISTS members (        -- 在籍の期間
    member_id TEXT PRIMARY KEY, joined_on TEXT NOT NULL, left_on TEXT  -- left_on は退会日=その日から在籍しない日(在籍中は NULL)
  );`);
  return db;
}

日時は、日本時間の 'YYYY-MM-DD HH:MM' の文字列で持ち、そのまま大小を比べています。手元で動かすための単純化です。予約の仕組みの本体でUTCと日本時間をどう分けて持つかは、予約の空き枠と締め切りを日本時間で扱う記事に書きました。予約の状態に「期限内の取り消し」と「期限後の取り消し」を分けて持つには、取り消しの受付の時点で期限と比べて書き分けます。パーソナルの枠とトレーナーのシフトの持ち方は、トレーナーのシフトと予約枠の記事で扱っています。

枠の稼働率を出すSQL

枠の稼働率は、上の表の扱いをそのままSQLに書きます。予約が1件もない枠も分母に入るよう、予約のない枠を0件として足しているのがポイントです。

metrics.mjs(枠の稼働率)js
// 稼働率の計算。分母と分子に「何を入れ、何を外すか」をSQLに書いておく
// 期間は [from, to)(to の日は含まない)。日時は日本時間の文字列のまま比べる
export function slotUtilization(db, from, to, kind = 'class') {
  return db.prepare(`
    SELECT
      COUNT(*)                                                   AS slots,          -- 休講を除いた枠の数
      SUM(s.capacity)                                            AS seats,          -- 定員の合計(分母)
      SUM(b.reserved)                                            AS reserved,       -- 予約(期限内の取り消しを除く。体験は除く)
      SUM(b.attended)                                            AS attended,       -- 来館(体験は除く)
      SUM(b.trial_attended)                                      AS trial_attended, -- 体験の来館(別に数える)
      ROUND(100.0 * SUM(b.reserved + b.trial_reserved) / SUM(s.capacity), 1) AS booked_pct,   -- 予約の稼働率(体験を含む)
      ROUND(100.0 * SUM(b.attended + b.trial_attended) / SUM(s.capacity), 1) AS attended_pct, -- 来館の稼働率(体験を含む)
      ROUND(100.0 * SUM(b.no_show) / NULLIF(SUM(b.reserved), 0), 1)          AS no_show_pct   -- 会員の予約のうち無断欠席
    FROM slots s
    JOIN (
      SELECT slot_id,
        SUM(is_trial = 0 AND status IN ('booked','late_cancel','no_show','attended')) AS reserved,
        SUM(is_trial = 1 AND status IN ('booked','late_cancel','no_show','attended')) AS trial_reserved,
        SUM(is_trial = 0 AND status = 'attended') AS attended,
        SUM(is_trial = 1 AND status = 'attended') AS trial_attended,
        SUM(is_trial = 0 AND status = 'no_show')  AS no_show
      FROM bookings GROUP BY slot_id
      UNION ALL                                     -- 予約が1件もない枠も、分母には入れる
      SELECT id, 0, 0, 0, 0, 0 FROM slots WHERE id NOT IN (SELECT slot_id FROM bookings)
    ) b ON b.slot_id = s.id
    WHERE s.status = 'open' AND s.kind = ? AND s.starts_at >= ? AND s.starts_at < ?`).get(kind, from, to);
}

同じファイルに、曜日×時間帯の表を出す関数、トレーナーの稼働率、会員の利用率の関数も書きました。トレーナーの稼働率は「来館のあったパーソナルの枠の時間 ÷ 勤務の時間」で、無断欠席だった枠は指導に使われた時間に入れていません。会員の利用率の分母は「期間の終わりの日に在籍していた会員」で、期間の途中で退会した人と、期間の後に入会した人は外しています。

metrics.mjs(トレーナーと会員・抜粋)js
// トレーナーの稼働率:勤務の時間のうち、パーソナルで来館があった枠の時間の割合
export function trainerUtilization(db, from, to) {
  return db.prepare(`
    SELECT w.trainer, w.work_min,
      COALESCE(p.used_min, 0) AS used_min,
      ROUND(100.0 * COALESCE(p.used_min, 0) / w.work_min, 1) AS pct
    FROM (SELECT trainer, SUM(minutes) AS work_min FROM shifts
          WHERE starts_at >= ? AND starts_at < ? GROUP BY trainer) w
    LEFT JOIN (SELECT s.trainer, SUM(s.minutes) AS used_min FROM slots s
          WHERE s.kind = 'personal' AND s.status = 'open' AND s.starts_at >= ? AND s.starts_at < ?
            AND EXISTS (SELECT 1 FROM bookings b WHERE b.slot_id = s.id AND b.status = 'attended')
          GROUP BY s.trainer) p ON p.trainer = w.trainer
    ORDER BY w.trainer`).all(from, to, from, to);
}

// 会員の利用率:期間の最終日に在籍していた会員のうち、期間内に1回以上来館した人の割合
export function memberActiveRate(db, from, to) {
  return db.prepare(`
    SELECT COUNT(*) AS members,
      SUM(EXISTS (SELECT 1 FROM bookings b JOIN slots s ON s.id = b.slot_id
                  WHERE b.member_id = m.member_id AND b.status = 'attended' AND b.is_trial = 0
                    AND s.starts_at >= ? AND s.starts_at < ?)) AS active,
      ROUND(100.0 * SUM(EXISTS (SELECT 1 FROM bookings b JOIN slots s ON s.id = b.slot_id
                  WHERE b.member_id = m.member_id AND b.status = 'attended' AND b.is_trial = 0
                    AND s.starts_at >= ? AND s.starts_at < ?)) / COUNT(*), 1) AS pct
    FROM members m
    WHERE m.joined_on < ? AND (m.left_on IS NULL OR m.left_on >= ?)`).get(from, to, from, to, to, to);
}

この記事の会員の利用率は、予約して来館した記録だけを数えています。24時間ジムのように予約なしで入館する店では、入退館の記録から数えます。入退館の記録の持ち方は、24時間ジムの入退館の記事に書きました。来ていない会員に声をかける仕組みは、会員データで退会を防ぐ記事で扱っています。

境目の扱いを、テストで固定する

境目の扱いは、あとから集計を直した人が気づかずに変えてしまいやすいところです。表で決めた扱いを、そのままテストに書いておきます。

metrics.test.mjs(抜粋)js
function setup() {
  const db = open();
  const slot = db.prepare('INSERT INTO slots (id, starts_at, minutes, kind, trainer, capacity, status) VALUES (?, ?, ?, ?, ?, ?, ?)');
  const book = db.prepare('INSERT INTO bookings (slot_id, member_id, is_trial, status) VALUES (?, ?, ?, ?)');
  slot.run(1, '2026-11-02 19:00', 60, 'class', 'A', 10, 'open');
  slot.run(2, '2026-11-03 19:00', 60, 'class', 'A', 10, 'cancelled'); // 休講
  slot.run(3, '2026-11-04 07:00', 60, 'class', 'B', 10, 'open');      // 予約0件
  for (const [i, s] of ['attended', 'attended', 'attended', 'no_show', 'late_cancel', 'cancelled', 'cancelled'].entries()) book.run(1, `m${i}`, 0, s);
  book.run(1, 't1', 1, 'attended');   // 体験
  for (let i = 0; i < 8; i++) book.run(2, `x${i}`, 0, 'booked');      // 休講の枠の予約
  return db;
}

test('期限内の取り消しは予約に数えず、期限後の取り消しと無断欠席は予約に数える', () => {
  const r = slotUtilization(setup(), '2026-11-02', '2026-11-09');
  assert.equal(r.reserved, 5);         // 来館3+無断欠席1+期限後の取り消し1
  assert.equal(r.attended, 3);
  assert.equal(r.booked_pct, 30);      // (会員5+体験1) / 20
  assert.equal(r.attended_pct, 20);    // (来館3+体験1) / 20
  assert.equal(r.no_show_pct, 20);     // 1 / 5
});

テストは6つ書き、すべて通りました(出力は、各テストの所要時間の表示、件数の行の一部、実験的な機能であることの警告を省いています)。

$ node --test --test-reporter=spec metrics.test.mjs
✔ 休講の枠は分母から外し、予約0件の枠は分母に入れる
✔ 期限内の取り消しは予約に数えず、期限後の取り消しと無断欠席は予約に数える
✔ 体験は会員の数字と分けて数える
✔ 期間の終わりの日は含まない
✔ トレーナーの稼働率は、来館のあったパーソナルの時間 ÷ 勤務の時間
✔ 会員の利用率は、期間の最終日に在籍していた会員が分母
ℹ tests 6
ℹ pass 6
ℹ fail 0

同じ1週間のデータでも、決め方で数字が変わる

決め方の違いがどれだけ数字に効くかを見るため、仮のデータで数え比べました。1週間(2026年11月2日〜8日)、1日4枠(7時・10時・19時・21時)、定員12人のクラスで、水曜19時の1枠を休講にしています。予約の数は、時間帯ごとの混み具合を決めて(土日の19時は平日の半分)、毎回同じになる乱数で作りました。出力からは、実験的な機能であることの警告を省いています。実在のジムの数字ではありません。

$ node demo.mjs
この記事の定義 [Object: null prototype] {
  slots: 27,
  seats: 324,
  reserved: 165,
  attended: 144,
  trial_attended: 9,
  booked_pct: 54,
  attended_pct: 47.2,
  no_show_pct: 4.8
}
期限内の取り消しも予約に数えた場合 63.3
休講の枠も分母に入れた場合 52.1

予約の稼働率(%)     月   火   水   木   金   土   日
07時                 50   50   50   50   42   50   33
10時                 42   42   25   42   33   33   42
19時                 92   83    休  100   92   50   50
21時                 67   67   67   67   42   50   50

この記事の定義では、予約の稼働率は54.0%、実際に来た人で数えると47.2%でした。期限内の取り消しも予約に数えると63.3%、休講の枠を分母に残すと52.1%になります。データは1件も変えていないのに、最も低い数え方と高い数え方で11ポイント余りの差が出ました。予約システムの画面の稼働率と、自分で集計した数字が合わないときは、まずこの境目のどれが違うかを確かめます。

曜日×時間帯の表は、時間割を見直すときの材料になります。この仮のデータでは、平日の19時は80%を超えて埋まり、10時は25〜42%にとどまっています。ただし、1週間だけでは偶然の波が大きいので、実際の判断には数か月分を並べ、季節や休みの時期の動きと分けて見ます。

数字を見て、次に何を決めるか

稼働率は、それだけで良し悪しを決める数字ではありません。数字の組み合わせから、次に確かめることを決めます。

見えた形 考えられること 次に確かめること
予約の稼働率は高いが、来館の稼働率との差が大きい 期限後の取り消しや無断欠席が多い 取り消しの期限、無断欠席の扱い、前日の連絡
特定の時間帯だけ低い 時間帯と、来られる人の生活が合っていない 数か月分の推移、クラスの内容、その時間帯の体験の申込
トレーナーの稼働率が人によって大きく違う 指名の偏り、シフトと予約の空きのずれ 指名の予約の数、シフトの時間帯と予約の時間帯
会員の利用率が下がっている 在籍したまま来ていない会員が増えている 最後に来た日からの日数ごとの人数、声かけの仕組み

前日の連絡を自動にする作りは、LINEで前日リマインドを送る記事にまとめています。空いている時間帯に体験を案内するなら、体験の申込の入口となるページやLPの見せ方も、あわせて見直します。

自社でやる場合と、任せる場合

自社で進める場合は、次の順にすると、数字の意味がぶれません。

  1. この記事の「入れるもの・外すもの」の表を、自分の店の決まりで埋めて書き残す。
  2. 予約の仕組みから、取り消しを消さずに状態として出せるか(期限内か期限後か、無断欠席か)を確かめる。出せない場合は、取り消しの記録の持ち方から直す。
  3. 1か月分を集計し、予約システムの画面の数字と並べて、違えばどの境目が違うかを書き残す。
  4. 毎月同じ集計を回し、曜日×時間帯の表と、来館の稼働率との差を見る。

任せる場合、株式会社bundlyzeでは、業種を問わず使える予約管理の仕組みを、企画から稼働後の運用まで受け持ってきました。予約や会員のデータから毎月の数字を自動で出す集計の仕組みづくりや、AWSでのデータ分析の基盤づくりもお受けしています。空いている時間帯に体験を案内するホームページやLPの制作も、あわせて相談できます。

動作確認した環境

確認日は2026年10月6日です。

  • macOS 26.6.2、Node.js 22.23.2(node:sqlite、node:test)
  • node:sqlite に組み込まれたSQLiteのバージョン:3.51.3(SELECT sqlite_version() で確認)
  • データはすべて、説明のために作った仮のもので、メモリ上のデータベースに入れています

確かめていないこと:

  • 実際の予約システムや会員管理システムからデータを取り出す部分(エクスポートの形式やAPIは製品ごとに違います)
  • 数か月分・複数店舗のデータでの速さ
  • PostgreSQLなど、ほかのデータベースでの動き。この記事のSQLは、SQLiteで真偽の式を数値として足し合わせる書き方(SUM(is_trial = 0 AND ...))を使っているので、ほかのデータベースでは CASE などに書き換えが要ることがあります

Node.js 22の node:sqlite は、実行すると「SQLite is an experimental feature and might change at any time」という警告を出します。ドキュメントでも開発中の機能(Stability: 1.1 - Active development)とされているので、本番の集計では、使うデータベースと接続のライブラリを別に選んでください。

出典(確認日:2026年10月6日)

SQLiteとNode.jsの動きについての説明は、次の公式のドキュメントの記載に基づいています。稼働率の定義そのものは、この記事で決めた例で、公的な定義や業界の基準ではありません。

  • strftime('%w', ...) が曜日を0〜6(日曜が0)で返すこと → SQLite「Date And Time Functions」
  • 窓関数(ROW_NUMBER() OVER ...。仮のデータで定員を超えた予約が出た場合に取り消し扱いにするために使用)が SQLite 3.25.0(2018-09-15)から使えること → SQLite「Window Functions」
  • node:sqlite の安定度(Stability: 1.1 - Active development)、v22.13.0 からフラグなしで使えるがまだ実験的であること → Node.js「SQLite」

よくある質問

ジムの稼働率は、どの数字のことを言えばよいですか?

決まった1つの定義はないので、社内で使う定義を先に決めて書き残します。この記事では、クラスやパーソナルの枠がどれだけ埋まったか(枠の稼働率)、トレーナーの勤務の時間のうちどれだけ指導に使われたか(トレーナーの稼働率)、在籍している会員のうち何人が実際に来たか(会員の利用率)の3つに分けました。同じ「稼働率」でも、何を知りたいかで見る数字が違います。

当日のキャンセルや無断欠席は、稼働率に入れますか?

この記事では、予約の稼働率には入れ、来館の稼働率には入れない、と分けました。期限を過ぎた取り消しと無断欠席は、ほかのお客様が予約できなかった枠なので「予約で埋まった枠」に数え、実際に来た人数とは分けて見ます。期限内の取り消しは、ほかの人が予約できたので、どちらにも数えません。

休講にしたクラスは、稼働率の分母に入れますか?

この記事では分母から外しました。休講の枠を分母に残すと、予約が入っていたのに休講にした枠も「埋まらなかった枠」として数えられ、稼働率が低く出ます。この記事の仮のデータでは、休講の1枠を分母に入れるかどうかで、予約の稼働率が54.0%と52.1%に分かれました。休講の数は、稼働率とは別に数えます。

予約システムの画面に出ている稼働率と、自分で集計した数字が合いません。

まず、分母と分子に何を入れているかを並べて比べます。休講の枠、期限内・期限後の取り消し、無断欠席、体験、期間の終わりの日を含めるか、のどれか1つが違うだけで数字は変わります。この記事の仮のデータでも、期限内の取り消しを予約に数えるかどうかだけで、54.0%と63.3%に分かれました。使っている予約システムの定義は、その提供元の説明で確かめてください。

この記事の書き方と確認の方針